-
Notifications
You must be signed in to change notification settings - Fork 2
Webhooks
Outbound event fan-out with HMAC signing, durable retry, and a queryable delivery log.
%%{init: {"look": "handDrawn", "theme": "dark"}}%%
sequenceDiagram
participant Route as CRM mutation
participant Emit as emit()
participant DB as Postgres
participant Worker
participant Target as your URL
Route->>Emit: contact.created
Emit->>DB: INSERT WebhookDelivery<br/>status=pending
Note over Worker: poll every 5s
Worker->>DB: claim pending
Worker->>Target: POST signed
alt 2xx
Worker->>DB: status=succeeded
else failure
Worker->>DB: attempts++, reschedule
Note over Worker: after WEBHOOK_MAX_RETRIES → dead
end
The worker runs in-process as a daemon thread started in FastAPI's
lifespan. It claims pending rows with
SELECT ... FOR UPDATE SKIP LOCKED so horizontal scaling is safe.
curl -X POST http://localhost:8000/webhooks \
-H "Authorization: Bearer nk_..." \
-H "Content-Type: application/json" \
-d '{
"name": "slack-relay",
"url": "https://hooks.slack.com/services/...",
"events": ["deal.stage_changed", "deal.won", "deal.lost"],
"is_active": true
}'Response carries a secret field — save it now, it's the HMAC key and
isn't retrievable later. Rotate by deleting + recreating.
events: ["*"] subscribes to everything.
contact.* company.* deal.*
deal.stage_changed
deal.won / deal.lost
activity.* note.* task.*
relationship.* file.*
memory.linked memory.unlinked
ingest.completed
Full list is in /schema under event_types.
{
"event": "deal.stage_changed",
"workspace_id": "uuid",
"entity_type": "deal",
"entity_id": "uuid",
"data": {
"from_stage_id": "uuid",
"to_stage_id": "uuid"
}
}Every request carries:
X-Nakatomi-Event: deal.stage_changed
X-Nakatomi-Signature: sha256=<hex>
X-Nakatomi-Delivery-Id: 42
X-Nakatomi-Delivery-Timestamp: 2026-04-18T15:12:00+00:00
Verify in Python:
import hmac, hashlib
def verify(secret: str, body: bytes, header: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
got = header.removeprefix("sha256=")
return hmac.compare_digest(expected, got)In Node:
const crypto = require("crypto");
function verify(secret, body, header) {
const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
const got = header.replace(/^sha256=/, "");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got));
}| Attempt | Delay before retry |
|---|---|
| 1st failure | 2s |
| 2nd | 8s |
| 3rd | 30s |
| 4th | 120s |
| 5th+ | 300s (capped) |
WEBHOOK_MAX_RETRIES (default 3) caps total attempts. After that the row
flips to status=dead and stops being retried. Tune via env.
A 2xx response at any point marks the delivery status=succeeded. Any
non-2xx or connection error counts as a failure.
curl -s "http://localhost:8000/webhooks/<id>/deliveries?limit=50" \
-H "Authorization: Bearer nk_..."Returns every attempt with status_code, response_body, error, attempts, and timestamps. Useful for debugging why a subscriber is flaky.
curl -X PATCH http://localhost:8000/webhooks/<id> \
-H "Authorization: Bearer nk_..." \
-d '{"is_active": false}'
curl -X DELETE http://localhost:8000/webhooks/<id> \
-H "Authorization: Bearer nk_..."Disabling keeps the row + delivery history; deleting cascades.
| Env var | Default | Meaning |
|---|---|---|
WEBHOOK_TIMEOUT_SECONDS |
10 | per-attempt HTTP timeout |
WEBHOOK_MAX_RETRIES |
3 | attempts before marking dead |
WEBHOOK_WORKER_ENABLED |
true | disable in tests / when running a dedicated worker process |
- Slack/Discord relay — format events as chat messages in a channel
-
Agent triggers — an outbound agent that reads
deal.stage_changedand drafts the next email - External BI — append to a data warehouse for cross-system dashboards
- Memory mirrors — replicate events to a custom store (though the built-in memory connectors usually cover this)
Repository · Issues · MIT licensed · maintained by Matt Dula