Skip to main content

Webhook Dispatcher — API Contracts

Status: populated Owner: Platform Engineering Last updated: 2026-04-18 Companion: EVENT_SCHEMAS · SECURITY_MODEL

1. REST API (via Kong Gateway)

Base path: /v1/webhooks Authentication: JWT Bearer (verified by Kong; accountId injected as X-Account-Id header) Content-Type: application/json


POST /v1/webhooks — Register Webhook

Request:

{
"url": "https://customer.example.com/webhooks/sms",
"secret": "super-secret-signing-key-32chars",
"description": "Production DLR handler",
"events": ["DLR_DELIVERED", "DLR_FAILED"]
}

Validation:

  • url: required; HTTPS; valid URL; max 2048 chars
  • secret: required; 16–128 chars
  • description: optional; max 255 chars
  • events: optional; defaults to all event types; must be valid WebhookEventType values

Response 201 Created:

{
"webhookId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"accountId": "d4e5f6a7-b8c9-0123-def0-234567890123",
"url": "https://customer.example.com/webhooks/sms",
"description": "Production DLR handler",
"events": ["DLR_DELIVERED", "DLR_FAILED"],
"isActive": true,
"createdAt": "2026-04-18T10:00:00Z"
}

Note: secret is NOT returned in the response.

Response 400 Bad Request (validation error):

{ "error": "VALIDATION_ERROR", "message": "url must use HTTPS scheme", "field": "url" }

Response 422 Unprocessable Entity (max webhooks exceeded):

{ "error": "MAX_WEBHOOKS_EXCEEDED", "message": "Maximum 10 active webhooks per account" }

GET /v1/webhooks — List Webhooks

Query params: page (default 1), limit (default 20, max 100)

Response 200 OK:

{
"data": [
{
"webhookId": "...",
"url": "https://...",
"description": "...",
"events": ["DLR_DELIVERED"],
"isActive": true,
"createdAt": "2026-04-18T10:00:00Z"
}
],
"meta": { "total": 2, "page": 1, "limit": 20 }
}

PUT /v1/webhooks/:id — Update Webhook

Request (partial update):

{
"url": "https://customer.example.com/webhooks/sms-v2",
"isActive": false
}

Allowed fields: url, secret, description, events, isActive.

Response 200 OK: Updated webhook object (same shape as POST response, no secret).

Response 404 Not Found (webhook not owned by account):

{ "error": "NOT_FOUND", "message": "Webhook not found" }

DELETE /v1/webhooks/:id — Delete Webhook

Response 204 No Content on success.

Deletion is hard-delete from webhook_configs; associated delivery_attempts rows are retained for audit.


GET /v1/webhooks/deliveries — List Delivery Attempts

Query params: webhookId (optional), status (optional), page, limit

Response 200 OK:

{
"data": [
{
"attemptId": "...",
"deliveryId": "...",
"webhookId": "...",
"eventId": "...",
"attemptNumber": 2,
"status": "FAILED_RETRY",
"httpStatusCode": 500,
"scheduledAt": "2026-04-18T10:05:00Z",
"nextRetryAt": "2026-04-18T10:35:00Z"
}
],
"meta": { "total": 15, "page": 1, "limit": 20 }
}

2. NATS — Consumed Subjects

webhook.dispatch

Produced by: dlr-processor Consumer: webhook-dispatcher (durable, AckExplicit, MaxConcurrency 20)

See EVENT_SCHEMAS for full schema.

Ack Semantics:

  • Message Acked immediately after delivery_attempts row(s) written to DB.
  • Retry scheduling is DB-driven; NATS message does not need to remain unacked during retry window.

3. NATS — Published Subjects

webhook.dispatch.deadletter

Published when all 5 delivery attempts for a (eventId, webhookId) pair are exhausted.

interface WebhookDeadLetterEvent {
eventId: string; // Original dispatch eventId
deliveryId: string; // UUIDv4
webhookId: string;
accountId: string;
reason: 'MAX_RETRIES_EXCEEDED';
attemptCount: 5;
lastHttpStatus?: number;
lastError?: string;
occurredAt: string; // ISO-8601 UTC
}

4. Outbound HTTP — Webhook Delivery

Request Format

POST https://customer.example.com/webhooks/sms HTTP/1.1
Content-Type: application/json
X-Ghasi-Signature: sha256=<64-char hex digest>
X-Ghasi-Event: DLR_DELIVERED
X-Ghasi-Delivery-Id: <deliveryId UUID>
X-Ghasi-Timestamp: 1713434625

Signature Generation

const signature = crypto
.createHmac('sha256', webhookSecret)
.update(rawJsonBody, 'utf8')
.digest('hex');
// Header value: `sha256=${signature}`

The signature is computed over the raw UTF-8 JSON bytes of the request body exactly as transmitted. The X-Ghasi-Timestamp is included in the signed payload to allow replay attack prevention on the customer side.

Request Body (Webhook Payload)

{
"id": "<deliveryId>",
"event": "DLR_DELIVERED",
"timestamp": 1713434625,
"data": {
"messageId": "c3d4e5f6-...",
"accountId": "d4e5f6a7-...",
"dlrStatus": "DELIVERED",
"to": "+441234567890",
"operatorId": "f47ac10b-...",
"occurredAt": "2026-04-18T10:23:46Z"
}
}

Success / Failure Criteria

ConditionOutcome
HTTP 2xx response within 5 sSUCCESS — no retry
HTTP 3xx redirectFAILED — no redirect follow; treated as failure
HTTP 4xx responseFAILED — counted as failure; retry scheduled
HTTP 5xx responseFAILED — retry scheduled
Connection timeout (> 5 s)FAILED — retry scheduled
DNS resolution failureFAILED — retry scheduled
TLS handshake failureFAILED — retry scheduled

5. Infrastructure Endpoints

PathPurpose
GET /healthLiveness probe
GET /readyReadiness probe (NATS + PG healthy)
GET /metricsPrometheus metrics