Skip to main content

Webhooks

Push, instead of polling. Register an HTTPS endpoint, subscribe it to the events you care about, and naturali POSTs a signed JSON envelope to it whenever one of them happens.

Overview

A webhook is an endpoint plus a subscription. A delivery is one event addressed to one endpoint, along with everything that happened to it — how many attempts, what status came back, what the receiver said.

Three things are true of every delivery, and they are the reason this is a module rather than an outbound fetch:

  • It is recorded before it is attempted. The delivery row is committed first, so an event survives a receiver being down and survives naturali restarting mid-request.
  • It is retried. A non-2xx or a connection failure is retried with exponential backoff, up to five attempts, and a delivery that exhausts them can still be replayed by hand.
  • It is signed. X-Naturali-Signature carries a timestamped HMAC-SHA256 over the exact request body. Verify it before you act on a payload.

See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Webhooks.

Data Model

Webhook

FieldTypeDescription
idstringPublic webhook ID (whk_ prefix).
project_idstringThe owning project.
urlstringWhere deliveries are POSTed.
eventsstring[]The subscription — exact types, resource.* wildcards, or *.
descriptionstring, nullableOperator-facing label.
activebooleanWhether deliveries are attempted.
created_atstring (date-time)
updated_atstring (date-time)
secretstringCreate and rotate responses only. The signing key (whsec_ prefix).

Delivery

FieldTypeDescription
idstringPublic delivery ID (whd_ prefix); sent as X-Naturali-Delivery.
project_idstringThe owning project.
webhook_idstringThe endpoint the delivery is addressed to.
event_idstringThe event's id — shared by every delivery and redelivery of it.
event_typestringThe event type; sent as X-Naturali-Event.
payloadobjectThe exact envelope that was signed and sent.
statuspending | success | failedpending until a 2xx (success) or the attempts run out (failed).
status_codeinteger, nullableThe receiver's HTTP status; null when there was no response at all.
attemptsintegerAttempts made so far.
next_attempt_atstring (date-time), nullableWhen the next retry is due; null once terminal.
last_attempt_atstring (date-time), nullable
response_bodystring, nullableA truncated snippet of the response, or the transport error.
created_atstring (date-time)
updated_atstring (date-time)

Event envelope

The body POSTed to your endpoint, and the value of a delivery's payload:

{
"id": "evt_V1StGXR8Z5jdHi6B",
"type": "task.created",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"resource_type": "task",
"resource_id": "task_V1StGXR8Z5jdHi6B",
"data": { "id": "task_V1StGXR8Z5jdHi6B", "title": "Reel: leftover coffee grounds" },
"created_at": "2026-07-31T00:00:00.000Z"
}

data is the resource shaped exactly as its own API returns it — a task.created payload carries the same object getTask would — so a receiver rarely needs a follow-up call.

Event catalog

EventFires whendata
task.createdA card is placed on a board.The task.
task.updatedA card is edited or moved through the API.The task.
conversation.startedAn address's first message on a channel opens a dialogue.The conversation.
message.receivedAn inbound message reaches a bound agent.conversation_id, channel_id, channel, identifier, session_id, provider_message_id, text.
knowledge.document_ingestedA knowledge document becomes retrievable.The document.
knowledge.ingest_failedA document's ingestion fails.The document.
generation.completedA synchronous generation finishes.id, agent_id, status, text, object, tool_calls.
generation.failedA synchronous generation ends in failure.The same.

This list is the whole contract. Subscribing to a name that is not on it is rejected at registration rather than accepted and silently never delivered — an endpoint that looks connected and never fires is indistinguishable from one where nothing has happened yet.

Two limits worth knowing before you design against it:

  • Streaming generations emit nothing. A stream: true generation is piped through to you token by token without being parsed, so naturali never learns how the turn ended.
  • A card that moves itself is not a task.updated. A column's own automation routing a card onward happens with no naturali request in flight, so this side of the boundary cannot see it. Cards moved through the API — by a person, by your integration — do fire.

Key Concepts

Subscriptions: exact, wildcard, or everything

Each entry in events is one of three things:

EntrySelects
task.createdExactly that event.
task.*Every task event, including ones added later.
*Everything.

A bare resource name (task, with no .*) matches nothing and is rejected — the near-miss that would otherwise look like a working subscription.

Verifying a delivery

Every request carries three headers:

HeaderValue
X-Naturali-EventThe event type.
X-Naturali-DeliveryThe delivery id (whd_…) — unique per attempt-chain.
X-Naturali-Signaturet=<unix seconds>,v1=<hex hmac>

The HMAC-SHA256 is computed over "<t>.<raw request body>" with your signing secret. Compute the same string, compare in constant time, and reject anything whose t is outside a window you choose — five minutes is a good default:

import { createHmac, timingSafeEqual } from 'node:crypto';

const verify = (rawBody, header, secret, toleranceSeconds = 300) => {
const parts = Object.fromEntries(
header.split(',').map((entry) => entry.trim().split('=')),
);
const timestamp = Number(parts.t);
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? '');
return a.length === b.length && timingSafeEqual(a, b);
};

Sign the raw bytes, before any JSON parsing or re-serialization — a framework that reformats the body will break the digest.

The timestamp is inside the signed string rather than merely alongside it, which is what makes the replay window enforceable: a captured delivery stops verifying once it ages out.

The secret is returned twice, ever

Create returns it, rotate returns it, and no endpoint reads it back. It is encrypted at rest, but that would buy nothing if an API-key-authenticated GET would hand it over — the key would simply become as good as the secret.

The cost is worth stating plainly: a lost secret is rotated, not recovered. Rotation takes effect on the next delivery, including retries of deliveries already queued, and there is no window in which both secrets verify — so roll the new one out to your receiver promptly. If you want to cut over gradually, register a second endpoint instead and delete the first once traffic has moved.

Retries, and what failed means

A delivery is attempted up to five times: immediately, then with exponential backoff and jitter — roughly five minutes of trying in total. Any non-2xx is retried, not just a 5xx: a receiver answering 400 is far more often mid-deploy or mis-configured than making a considered judgement about the payload.

After the last attempt the delivery is failed. Nothing is lost — the payload is kept, and POST /v1/projects/{project_id}/webhook-deliveries/{delivery_id}:redeliver sends it again. Redelivery creates a new delivery and leaves the original's history intact, since that history is the evidence you redelivered on.

Two consequences for your receiver:

  • Answer fast, work later. Respond 2xx as soon as you have durably accepted the event; a request that takes longer than ten seconds is abandoned and retried.
  • Expect duplicates. A receiver that answers slowly, or one you redeliver to, will see an event twice. Dedupe on the event's id, which is stable across redeliveries — X-Naturali-Delivery deliberately is not.

Turning an endpoint off without losing its history

active: false stops deliveries and keeps the endpoint and its delivery log — the reversible half of DELETE /v1/projects/{project_id}/webhooks/{webhook_id}, and what to reach for while a receiver is being repaired. DELETE removes the endpoint and its deliveries.

Ingestion events are observed, not awaited

knowledge.document_ingested and knowledge.ingest_failed are the one pair whose timing is not exact. Indexing finishes outside any naturali request, so the event fires when naturali next sees the document's terminal status: on the create or :reingest call itself for an inline-text document, which is the common case, and otherwise on the next read of that document or of the list it is in. Each transition fires once.

Examples

Register an endpoint

naturali create-webhook \
--project-id proj_V1StGXR8Z5jdHi6B \
--url https://example.com/hooks/naturali \
--events task.* \
--events message.received \
--description "billing service"

Audit what was delivered

naturali list-webhook-deliveries \
--project-id proj_V1StGXR8Z5jdHi6B \
--webhook-id whk_V1StGXR8Z5jdHi6B \
--status failed

naturali get-webhook-delivery \
--project-id proj_V1StGXR8Z5jdHi6B \
--delivery-id whd_V1StGXR8Z5jdHi6B

Replay a failed delivery

naturali redeliver-webhook-delivery \
--project-id proj_V1StGXR8Z5jdHi6B \
--delivery-id whd_V1StGXR8Z5jdHi6B

Pause an endpoint, then rotate its secret

naturali update-webhook \
--project-id proj_V1StGXR8Z5jdHi6B \
--webhook-id whk_V1StGXR8Z5jdHi6B \
--active false

naturali rotate-webhook-secret \
--project-id proj_V1StGXR8Z5jdHi6B \
--webhook-id whk_V1StGXR8Z5jdHi6B