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 charssecret: required; 16–128 charsdescription: optional; max 255 charsevents: optional; defaults to all event types; must be validWebhookEventTypevalues
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_attemptsrow(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
| Condition | Outcome |
|---|---|
| HTTP 2xx response within 5 s | SUCCESS — no retry |
| HTTP 3xx redirect | FAILED — no redirect follow; treated as failure |
| HTTP 4xx response | FAILED — counted as failure; retry scheduled |
| HTTP 5xx response | FAILED — retry scheduled |
| Connection timeout (> 5 s) | FAILED — retry scheduled |
| DNS resolution failure | FAILED — retry scheduled |
| TLS handshake failure | FAILED — retry scheduled |
5. Infrastructure Endpoints
| Path | Purpose |
|---|---|
GET /health | Liveness probe |
GET /ready | Readiness probe (NATS + PG healthy) |
GET /metrics | Prometheus metrics |