This document describes every webhook-related endpoint provided by the TalentTrust Backend API. It covers webhook subscription management, metrics recording, and DLQ replay operations.
The webhooks system consists of three primary API groups:
- Subscription Management (
/api/v1/webhook-subscriptions/*) — Create, read, update, and delete webhook subscriptions - Metrics Recording (
/api/v1/metrics/webhook/*) — Record webhook delivery outcomes and DLQ metrics - Admin Operations (
/api/v1/admin/webhooks/*) — Replay dead-letter queue entries
All endpoints follow the standard TalentTrust API response envelope contract (see API.md). All timestamps are ISO 8601 formatted strings.
Webhook subscriptions define event delivery endpoints for a consumer or globally. Each subscription specifies a target URL, event type to subscribe to, and an optional shared secret for HMAC signature verification.
Purpose: Create a new webhook subscription.
Access: Requires admin role (JWT Bearer token).
Request Headers:
Authorization: Bearer <jwt>(required) — Admin user JWT tokenContent-Type: application/json(required)X-Request-Id(optional) — Unique request identifier (UUID v4 or alphanumeric, max 128 chars)X-Correlation-Id(optional) — Correlation ID for distributed tracing (max 128 chars, alphanumeric + hyphens/underscores)
Request Body:
{
"consumerId": "00000000-0000-0000-0000-000000000001",
"url": "https://webhook.example.com/events",
"eventType": "contract.created",
"secret": "shared-webhook-secret-key"
}Parameters:
consumerId(optional, string) — UUID of the consumer who owns this subscription. If omitted, the subscription is global.url(required, string) — Target URL for webhook delivery. Must be a valid HTTPS URL. URLs resolving to private/reserved addresses (localhost, 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16) are rejected.eventType(required, string) — Event type to subscribe to. Length 1–100 characters.secret(optional, string) — Shared signing secret. Length 1–256 characters. If provided, outbound webhooks will be signed with HMAC-SHA256 using this secret. If omitted, no signature is generated.
Success Response (201 Created):
{
"status": "success",
"data": {
"id": "12345678-1234-1234-1234-123456789abc",
"consumerId": "00000000-0000-0000-0000-000000000001",
"url": "https://webhook.example.com/events",
"eventType": "contract.created",
"active": true,
"createdAt": "2024-07-20T14:30:00.000Z",
"updatedAt": "2024-07-20T14:30:00.000Z"
}
}Note: The secret field is never included in API responses. Secrets are stored server-side and used only for outbound signature generation.
Error Responses:
| Status | Code | Condition | Example |
|---|---|---|---|
| 400 | validation_error |
Invalid URL format (not a valid HTTP/HTTPS URL). | See validation error details below. |
| 400 | invalid_url |
URL resolves to a private/reserved address (SSRF protection). | { "error": { "code": "invalid_url", "message": "Provided URL is invalid or resolved to a private/reserved address.", "requestId": "..." } } |
| 400 | validation_error |
Missing required fields, wrong types, or invalid field lengths. | { "error": { "code": "validation_error", "message": "Request validation failed", "requestId": "...", "details": [ { "field": "eventType", "message": "String must contain at least 1 character(s)" } ] } } |
| 401 | unauthorized |
Missing or invalid Authorization header. |
{ "error": { "code": "unauthorized", "message": "Authentication is required", "requestId": "..." } } |
| 403 | forbidden |
User is authenticated but does not have the admin role. |
{ "error": { "code": "forbidden", "message": "You do not have permission to perform this action", "requestId": "..." } } |
| 500 | internal_error |
Unexpected server error during database write. | { "error": { "code": "internal_error", "message": "An unexpected error occurred", "requestId": "..." } } |
Example Request:
curl -X POST http://localhost:3001/api/v1/webhook-subscriptions \
-H "Authorization: Bearer demo-admin-token" \
-H "Content-Type: application/json" \
-H "X-Request-Id: 550e8400-e29b-41d4-a716-446655440000" \
-d '{
"consumerId": "00000000-0000-0000-0000-000000000001",
"url": "https://webhook.example.com/events",
"eventType": "contract.created",
"secret": "my-shared-secret-key"
}'Example Response (201):
{
"status": "success",
"data": {
"id": "87654321-4321-4321-4321-987654321def",
"consumerId": "00000000-0000-0000-0000-000000000001",
"url": "https://webhook.example.com/events",
"eventType": "contract.created",
"active": true,
"createdAt": "2024-07-20T14:30:00.000Z",
"updatedAt": "2024-07-20T14:30:00.000Z"
}
}Purpose: List all webhook subscriptions with optional filtering and cursor-based pagination.
Access: Requires admin role (JWT Bearer token).
Request Headers:
Authorization: Bearer <jwt>(required)X-Request-Id(optional)X-Correlation-Id(optional)
Query Parameters:
consumerId(optional, string) — Filter by consumer UUID.eventType(optional, string) — Filter by event type.active(optional, string or boolean) — Filter by active status. Acceptstrueorfalse(case-insensitive strings or JSON boolean).cursor(optional, string) — Pagination cursor from the previous page's response. Omit on first request.limit(optional, integer) — Number of results per page. Default: 20, Min: 1, Max: 100.
Success Response (200 OK):
{
"status": "success",
"data": {
"data": [
{
"id": "12345678-1234-1234-1234-123456789abc",
"consumerId": "00000000-0000-0000-0000-000000000001",
"url": "https://webhook.example.com/events",
"eventType": "contract.created",
"active": true,
"createdAt": "2024-07-20T14:30:00.000Z",
"updatedAt": "2024-07-20T14:30:00.000Z"
}
],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI0LTA3LTIwVDEwOjAwOjAwLjAwMFoiLCJpZCI6IjEyMzQ1Njc4LTEyMzQtMTIzNC0xMjM0LTEyMzQ1Njc4OWFiYyJ9",
"hasNextPage": true,
"limit": 20
}
}Note: nextCursor is null when the current page is the last page. Results are ordered by createdAt DESC, with id DESC as a tie-breaker.
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | validation_error |
Invalid limit (exceeds 100, non-positive, or not an integer) or malformed cursor. |
| 401 | unauthorized |
Missing or invalid authentication. |
| 403 | forbidden |
User lacks admin role. |
| 500 | internal_error |
Unexpected database error. |
Example Request:
curl -X GET "http://localhost:3001/api/v1/webhook-subscriptions?eventType=contract.created&limit=10" \
-H "Authorization: Bearer demo-admin-token"Example Response (200):
{
"status": "success",
"data": {
"data": [
{
"id": "87654321-4321-4321-4321-987654321def",
"consumerId": "00000000-0000-0000-0000-000000000001",
"url": "https://webhook.example.com/events",
"eventType": "contract.created",
"active": true,
"createdAt": "2024-07-20T14:30:00.000Z",
"updatedAt": "2024-07-20T14:30:00.000Z"
}
],
"nextCursor": null,
"hasNextPage": false,
"limit": 10
}
}Purpose: Retrieve a single webhook subscription by ID.
Access: Requires admin role (JWT Bearer token).
Request Headers:
Authorization: Bearer <jwt>(required)X-Request-Id(optional)X-Correlation-Id(optional)
Path Parameters:
id(required, string) — UUID of the subscription to retrieve.
Success Response (200 OK):
{
"status": "success",
"data": {
"id": "12345678-1234-1234-1234-123456789abc",
"consumerId": "00000000-0000-0000-0000-000000000001",
"url": "https://webhook.example.com/events",
"eventType": "contract.created",
"active": true,
"createdAt": "2024-07-20T14:30:00.000Z",
"updatedAt": "2024-07-20T14:30:00.000Z"
}
}Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | validation_error |
Invalid UUID format in path parameter. |
| 401 | unauthorized |
Missing or invalid authentication. |
| 403 | forbidden |
User lacks admin role. |
| 404 | not_found |
Subscription with the given ID does not exist. |
| 500 | internal_error |
Unexpected database error. |
Example Request:
curl -X GET http://localhost:3001/api/v1/webhook-subscriptions/12345678-1234-1234-1234-123456789abc \
-H "Authorization: Bearer demo-admin-token"Example Response (404):
{
"error": {
"code": "not_found",
"message": "Webhook subscription not found.",
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}
}Purpose: Update an existing webhook subscription.
Access: Requires admin role (JWT Bearer token).
Request Headers:
Authorization: Bearer <jwt>(required)Content-Type: application/json(required)X-Request-Id(optional)X-Correlation-Id(optional)
Path Parameters:
id(required, string) — UUID of the subscription to update.
Request Body (all fields optional):
{
"url": "https://new-webhook.example.com/events",
"eventType": "contract.updated",
"secret": "new-shared-secret",
"active": false
}Parameters:
url(optional, string) — New target URL. Must be valid HTTPS and not resolve to private/reserved addresses.eventType(optional, string) — New event type. Length 1–100 characters.secret(optional, string) — New signing secret. Length 1–256 characters.active(optional, boolean) — Enable/disable the subscription.
Success Response (200 OK):
{
"status": "success",
"data": {
"id": "12345678-1234-1234-1234-123456789abc",
"consumerId": "00000000-0000-0000-0000-000000000001",
"url": "https://new-webhook.example.com/events",
"eventType": "contract.updated",
"active": false,
"createdAt": "2024-07-20T14:30:00.000Z",
"updatedAt": "2024-07-20T15:45:30.000Z"
}
}Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | validation_error |
Invalid UUID in path or invalid field values. |
| 400 | invalid_url |
URL resolves to a private/reserved address. |
| 401 | unauthorized |
Missing or invalid authentication. |
| 403 | forbidden |
User lacks admin role. |
| 404 | not_found |
Subscription with the given ID does not exist. |
| 500 | internal_error |
Unexpected database error. |
Example Request:
curl -X PATCH http://localhost:3001/api/v1/webhook-subscriptions/12345678-1234-1234-1234-123456789abc \
-H "Authorization: Bearer demo-admin-token" \
-H "Content-Type: application/json" \
-d '{
"active": false
}'Example Response (200):
{
"status": "success",
"data": {
"id": "12345678-1234-1234-1234-123456789abc",
"consumerId": "00000000-0000-0000-0000-000000000001",
"url": "https://webhook.example.com/events",
"eventType": "contract.created",
"active": false,
"createdAt": "2024-07-20T14:30:00.000Z",
"updatedAt": "2024-07-20T15:45:30.000Z"
}
}Purpose: Delete a webhook subscription permanently.
Access: Requires admin role (JWT Bearer token).
Request Headers:
Authorization: Bearer <jwt>(required)X-Request-Id(optional)X-Correlation-Id(optional)
Path Parameters:
id(required, string) — UUID of the subscription to delete.
Success Response (200 OK):
{
"status": "success",
"data": {
"id": "12345678-1234-1234-1234-123456789abc",
"deleted": true
}
}Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | validation_error |
Invalid UUID format. |
| 401 | unauthorized |
Missing or invalid authentication. |
| 403 | forbidden |
User lacks admin role. |
| 404 | not_found |
Subscription with the given ID does not exist. |
| 500 | internal_error |
Unexpected database error. |
Example Request:
curl -X DELETE http://localhost:3001/api/v1/webhook-subscriptions/12345678-1234-1234-1234-123456789abc \
-H "Authorization: Bearer demo-admin-token"Example Response (404):
{
"error": {
"code": "not_found",
"message": "Webhook subscription not found.",
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}
}These endpoints allow internal services and monitoring systems to record webhook delivery outcomes and dead-letter queue metrics. They are intended for internal use (e.g., called by background job processors).
Purpose: Record the outcome of a webhook delivery attempt.
Access: Should be protected by metricsAuthMiddleware in production. Currently unprotected for development.
Request Headers:
Content-Type: application/json(required)X-Request-Id(optional)X-Correlation-Id(optional)
Request Body:
{
"outcome": "success"
}Parameters:
outcome(required, string) — Delivery outcome. Must be one of:"success","failure","dlq".
Success Response (204 No Content):
(empty body)
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | validation_error |
Missing outcome field, invalid value, unknown fields present, or outcome is not a string. |
| 500 | internal_error |
Unexpected error while recording the metric. |
Validation Error Details:
The details array in a 400 response provides field-level validation information:
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"details": [
{
"field": "outcome",
"message": "outcome must be one of: success, failure, dlq"
}
]
}
}Example Request (success):
curl -X POST http://localhost:3001/api/v1/metrics/webhook/delivery \
-H "Content-Type: application/json" \
-d '{
"outcome": "success"
}'Example Request (failure):
curl -X POST http://localhost:3001/api/v1/metrics/webhook/delivery \
-H "Content-Type: application/json" \
-d '{
"outcome": "failure"
}'Example Response (400 validation error):
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"details": [
{
"field": "outcome",
"message": "Invalid enum value. Expected 'success' | 'failure' | 'dlq'"
}
]
}
}Purpose: Set the current depth of the webhook dead-letter queue (DLQ) gauge.
Access: Should be protected by metricsAuthMiddleware in production. Currently unprotected for development.
Request Headers:
Content-Type: application/json(required)X-Request-Id(optional)X-Correlation-Id(optional)
Request Body:
{
"depth": 42
}Parameters:
depth(required, integer) — Current DLQ depth. Must be a non-negative integer in the range [0, 10,000,000].
Success Response (204 No Content):
(empty body)
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | validation_error |
depth is missing, not an integer, negative, non-finite (NaN/Infinity), exceeds 10,000,000, or unknown fields present. |
| 500 | internal_error |
Unexpected error while setting the metric. |
Validation Error Details:
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"details": [
{
"field": "depth",
"message": "DLQ depth must be an integer"
}
]
}
}Example Request (valid):
curl -X POST http://localhost:3001/api/v1/metrics/webhook/dlq-depth \
-H "Content-Type: application/json" \
-d '{
"depth": 100
}'Example Response (400 validation error — exceeds max):
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"details": [
{
"field": "depth",
"message": "DLQ depth must be <= 10000000"
}
]
}
}Example Response (400 validation error — negative):
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"details": [
{
"field": "depth",
"message": "DLQ depth must be >= 0"
}
]
}
}Purpose: Replay all pending dead-letter queue (DLQ) entries with controlled concurrency. This triggers retransmission of failed webhooks.
Access: Requires admin role (JWT Bearer token).
Request Headers:
Authorization: Bearer <jwt>(required)Content-Type: application/json(optional)X-Request-Id(optional)X-Correlation-Id(optional)
Request Body (optional):
{
"concurrency": 5
}Parameters:
concurrency(optional, integer) — Maximum number of concurrent replays. Default: 5, Min: 1, Max: 50. Non-integer values are floored; values outside the range are clamped. If not provided, defaults to 5.
Success Response (200 OK):
{
"status": "success",
"data": {
"attempted": 42,
"succeeded": 40,
"failed": 2,
"deduped": 0
}
}Response Fields:
attempted— Total number of DLQ entries processed.succeeded— Number of successful re-deliveries.failed— Number of entries that failed re-delivery and remain in the DLQ.deduped— Number of entries skipped because they were already replayed (idempotency).
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 401 | unauthorized |
Missing or invalid Authorization header. |
| 403 | forbidden |
User is authenticated but does not have the admin role. |
| 500 | internal_error |
Unexpected error during replay. |
Example Request (with concurrency override):
curl -X POST http://localhost:3001/api/v1/admin/webhooks/dlq/replay-all \
-H "Authorization: Bearer demo-admin-token" \
-H "Content-Type: application/json" \
-d '{
"concurrency": 10
}'Example Request (using default concurrency):
curl -X POST http://localhost:3001/api/v1/admin/webhooks/dlq/replay-all \
-H "Authorization: Bearer demo-admin-token"Example Response (200):
{
"status": "success",
"data": {
"attempted": 42,
"succeeded": 40,
"failed": 2,
"deduped": 0
}
}When a subscription includes a secret, outbound webhooks are signed using HMAC-SHA256. Integrators must verify signatures on received webhooks to confirm authenticity. This section documents the signature scheme.
Each signed webhook includes two headers:
X-Signature— The HMAC-SHA256 digest of the webhook payload, optionally prefixed withsha256=. Format: lowercase hex (64 characters).X-Timestamp— Unix timestamp in milliseconds when the webhook was generated.
-
Extract headers:
signature= value ofX-Signatureheadertimestamp= value ofX-Timestampheader (parse as integer milliseconds)
-
Validate timestamp freshness:
- Calculate age =
now - timestamp - Reject if age > 5 minutes (300,000 ms)
- Error code:
unauthorized, message:"Webhook timestamp is too old"
- Calculate age =
-
Normalize the signature:
- Strip the optional
sha256=prefix if present - Verify the result is a valid hex string of exactly 64 characters
- If invalid, reject with error code
bad_request, message:"Webhook signature format is invalid"
- Strip the optional
-
Reconstruct the canonical string:
- Format:
"${timestamp}.${JSON.stringify(webhookPayload)}" - Use standard JSON serialization with no extra whitespace
- Format:
-
Compute the expected signature:
- Use HMAC-SHA256 with the shared
secretfrom your subscription - Hash the canonical string
- Output as lowercase hex
- Use HMAC-SHA256 with the shared
-
Compare signatures:
- Use constant-time comparison (e.g.,
crypto.timingSafeEqualin Node.js) - If they match, the webhook is authentic
- If they don't match, reject with error code
invalid_webhook_signature, message:"Webhook signature does not match"
- Use constant-time comparison (e.g.,
| Code | Status | Message | Cause |
|---|---|---|---|
valid |
N/A | Webhook signature is valid | Signature and timestamp both valid |
unauthorized |
401 | Webhook timestamp is too old | Timestamp older than 5 minutes |
bad_request |
400 | Webhook timestamp is invalid | Timestamp missing, malformed, non-finite, or ≤ 0 |
bad_request |
400 | Webhook signature format is invalid | Signature not valid hex or wrong length |
bad_request |
400 | Webhook secret is required | Secret not provided to verification function |
invalid_webhook_signature |
403 | Webhook signature does not match | HMAC mismatch (tampering or wrong secret) |
const crypto = require('crypto');
function verifyWebhookSignature(payload, signatureHeader, timestampHeader, secret) {
const timestamp = parseInt(timestampHeader, 10);
const now = Date.now();
const maxAgeMs = 5 * 60 * 1000; // 5 minutes
// Check timestamp freshness
if (now - timestamp > maxAgeMs) {
throw new Error('Webhook timestamp is too old');
}
// Normalize signature (strip optional sha256= prefix)
let sig = signatureHeader;
if (sig.toLowerCase().startsWith('sha256=')) {
sig = sig.slice(7);
}
// Verify hex format
if (!/^[a-f0-9]{64}$/i.test(sig)) {
throw new Error('Webhook signature format is invalid');
}
// Reconstruct canonical string
const canonicalString = `${timestamp}.${JSON.stringify(payload)}`;
// Compute expected signature
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(canonicalString)
.digest('hex');
// Constant-time comparison
if (!crypto.timingSafeEqual(Buffer.from(sig.toLowerCase()), Buffer.from(expectedSignature))) {
throw new Error('Webhook signature does not match');
}
return true;
}
// Example usage
const payload = { event: 'contract.created', data: { id: 'abc123' } };
const signature = 'sha256=abcd...'; // from X-Signature header
const timestamp = '1721507400000'; // from X-Timestamp header
const secret = 'my-shared-secret';
try {
verifyWebhookSignature(payload, signature, timestamp, secret);
console.log('Webhook verified successfully');
} catch (err) {
console.error('Verification failed:', err.message);
}If a subscription is created without a secret, outbound webhooks are not signed. The X-Signature and X-Timestamp headers will not be present. You can still verify the webhook came from TalentTrust by:
- Checking the source IP address (if your network allows it)
- Using a firewall rule or API gateway to restrict access
- Verifying the webhook URL matches your subscription
We recommend always providing a secret for security.
Condition: Subscription URL is not HTTPS or is malformed.
Response Status: 400 Bad Request
Response Code: validation_error
Fix: Provide a valid HTTPS URL.
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"requestId": "...",
"details": [
{
"field": "url",
"message": "Invalid url"
}
]
}
}Condition: URL resolves to 127.0.0.1, localhost, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, or 169.254.0.0/16.
Response Status: 400 Bad Request
Response Code: invalid_url
Fix: Use a public URL that is externally reachable.
{
"error": {
"code": "invalid_url",
"message": "Provided URL is invalid or resolved to a private/reserved address.",
"requestId": "..."
}
}Condition: POST/PATCH request is missing required fields or includes unknown fields.
Response Status: 400 Bad Request
Response Code: validation_error
Fix: Ensure all required fields are present and no extra fields are included.
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"requestId": "...",
"details": [
{
"field": "url",
"message": "Required"
}
]
}
}Condition: GET, PATCH, or DELETE request targets a non-existent subscription.
Response Status: 404 Not Found
Response Code: not_found
Fix: Verify the subscription ID is correct and the subscription has not been deleted.
{
"error": {
"code": "not_found",
"message": "Webhook subscription not found.",
"requestId": "..."
}
}Condition: Request is authenticated but user lacks admin role.
Response Status: 403 Forbidden
Response Code: forbidden
Fix: Use an admin token or request a user with admin privileges perform the operation.
{
"error": {
"code": "forbidden",
"message": "You do not have permission to perform this action",
"requestId": "..."
}
}- API.md — General API conventions, response envelopes, and error handling
- authentication-authorization.md — JWT authentication and role-based access control
- queue-system.md — Background job queue architecture
- DLQ Implementation — Dead-letter queue design and operations