Skip to content

Webhooks

Matt Dula edited this page Apr 18, 2026 · 2 revisions

Webhooks

Outbound event fan-out with HMAC signing, durable retry, and a queryable delivery log.

How the queue works

%%{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
Loading

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.

Create a subscription

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.

Event catalog

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.

Payload shape

{
  "event": "deal.stage_changed",
  "workspace_id": "uuid",
  "entity_type": "deal",
  "entity_id": "uuid",
  "data": {
    "from_stage_id": "uuid",
    "to_stage_id": "uuid"
  }
}

Signature verification

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));
}

Retry semantics

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.

Inspect deliveries

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.

Disable / delete

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.

Config

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

Common subscribers

  • Slack/Discord relay — format events as chat messages in a channel
  • Agent triggers — an outbound agent that reads deal.stage_changed and 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)

Clone this wiki locally