Webhook Dispatcher — Application Logic
Status: populated Owner: Platform Engineering Last updated: 2026-04-18 Companion: DOMAIN_MODEL · DATA_MODEL · FAILURE_MODES
1. Dispatch Pipeline (NATS Consumer Path)
[NATS webhook.dispatch]
│
▼
1. Deserialise + validate (Zod)
│
▼
2. Lookup active WebhookConfigs for accountId + event filter match
│
┌────┴────┐
none one or more
│ │
ACK 3. For each WebhookConfig:
Generate deliveryId (UUID, stable per eventId+webhookId)
Insert delivery_attempts row (status=PENDING, attempt_number=1, scheduled_at=now())
ACK NATS message
│
▼
4. Execute first attempt immediately (async post-ack)
│
┌─────┴─────┐
2xx non-2xx / timeout
│ │
5a. Mark SUCCESS 5b. Schedule retry
Update delivery_attempts:
status=FAILED_RETRY, next_retry_at=now()+delay
2. Retry Worker (Scheduled Poller)
Runs every 10 seconds in-process:
SELECT da.*, wc.url, wc.secret_enc
FROM hook.delivery_attempts da
JOIN hook.webhook_configs wc ON wc.webhook_id = da.webhook_id
WHERE da.status IN ('PENDING','FAILED_RETRY')
AND da.next_retry_at <= now()
ORDER BY da.next_retry_at ASC
LIMIT 50
FOR UPDATE SKIP LOCKED;
For each row returned:
- Decrypt
wc.secret_enc→ plaintext secret - Reconstruct payload from
da.payload_snapshot - Execute HTTP delivery (see §3)
- Update
delivery_attemptsbased on result
3. HTTP Delivery
async function deliver(url: string, secret: string, payload: object, deliveryId: string): Promise<DeliveryResult> {
const body = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000);
const signature = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex');
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Ghasi-Signature': `sha256=${signature}`,
'X-Ghasi-Event': payload.event,
'X-Ghasi-Delivery-Id': deliveryId,
'X-Ghasi-Timestamp': String(timestamp),
},
body,
signal: AbortSignal.timeout(5_000), // 5 second timeout
redirect: 'manual', // do not follow redirects
});
return {
success: response.status >= 200 && response.status < 300,
httpStatusCode: response.status,
responseBody: (await response.text()).slice(0, 512),
};
}
Redirect handling: HTTP 3xx responses are treated as failure; redirect: 'manual' prevents automatic following.
4. Retry Backoff Schedule
attemptNumber | next_retry_at delay |
|---|---|
| 1 (first) | Immediate |
| 2 | + 30 seconds |
| 3 | + 5 minutes |
| 4 | + 30 minutes |
| 5 | + 2 hours |
| 5 (exhausted) | Dead-letter; next_retry_at += 8 hours (deadline for alerting reference only) |
const RETRY_DELAYS_MS = [0, 30_000, 300_000, 1_800_000, 7_200_000];
function nextRetryAt(attemptNumber: number): Date | null {
if (attemptNumber >= 5) return null; // dead-letter
const delayMs = RETRY_DELAYS_MS[attemptNumber]; // 0-indexed: attempt 1 → index 0
return new Date(Date.now() + delayMs);
}
5. Dead-Letter Path
When attempt 5 fails:
- Update
delivery_attempts:status = DEAD_LETTER,next_retry_at = NULL. - Publish
webhook.dispatch.deadletterto NATS (direct publish, not via outbox). - Increment
hook_deliveries_dead_lettered_totalcounter. - Log
WARN hook.dead_letteredwithdeliveryId,webhookId,accountId,lastHttpStatus.
6. Event Filter Matching
When processing a webhook.dispatch event with dlrStatus = 'FAILED':
const eventType = dlrStatusToWebhookEventType(dlrStatus); // 'DLR_FAILED'
const matchingWebhooks = webhooks.filter(w =>
w.isActive && w.events.includes(eventType)
);
A webhook with events: ['DLR_DELIVERED'] does NOT receive DLR_FAILED dispatches.
7. NATS Consumer Configuration
const consumerConfig = {
name: 'webhook-dispatcher',
durable_name: 'webhook-dispatcher',
ack_policy: AckPolicy.Explicit,
ack_wait: 15_000_000_000, // 15s (short: Ack before HTTP attempt)
max_ack_pending: 20,
deliver_policy: DeliverPolicy.All,
filter_subject: 'webhook.dispatch',
};
Note: NATS message is Acked after DB write (before HTTP attempt). Retry is DB-driven, not NATS-driven.
8. REST API Logic
POST /v1/webhooks
- Validate request body with Zod schema.
- Check account webhook count ≤ 10.
- Encrypt
secretwith AES-256-GCM using KMS-derived key. - Insert into
hook.webhook_configs. - Return created record (secret not returned).
PUT /v1/webhooks/:id
- Verify webhook belongs to requesting
accountId. - If
secretupdated: re-encrypt before persisting. - Update
updated_at = now().
DELETE /v1/webhooks/:id
- Verify ownership.
- Hard delete from
webhook_configs. - Pending
delivery_attemptsfor this webhook retain their rows but will not be retried (webhook config gone — retry worker skips missing webhook).