Skip to main content

Webhooks

HTTP callbacks that deliver signed event notifications when project resources change.

Overview

A webhook is scoped to a project. When you create a webhook you specify a URL and a list of event patterns to subscribe to. The server dispatches matching events automatically, retrying up to three times for failed deliveries. Every delivery is recorded as a row before the first HTTP attempt and retried from that row, so a restart mid-delivery does not lose it — see Delivery durability. Every delivery is signed with HMAC-SHA256 so receivers can verify authenticity.

Webhooks are outbound — SOAT calls your endpoint when events occur. For the inbound direction — an external system calling SOAT to activate an orchestration, agent, or tool — see Triggers, whose webhook starter verifies an incoming HMAC signature the same way.

To start work inside SOAT when an event fires, do not wire a webhook subscription back to your own deployment's inbound hook — bind an event trigger to the same event pattern instead. It subscribes to the bus directly, so there is no public URL, no signature to verify against your own event, and no second retry policy for one logical hop.

See the Permissions Reference for the IAM action strings for this module.

Data Model

Webhook

FieldTypeDescription
idstringPublic identifier
project_idstringID of the owning project
policy_idstring | nullOptional policy that gates delivery
namestringHuman-readable name
descriptionstring | nullOptional description
urlstringHTTPS endpoint that receives deliveries
eventsstring[]List of event patterns to subscribe to
activebooleanWhether the webhook is enabled
secretstringReturned only on create and secret rotation
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

Webhook Delivery

FieldTypeDescription
idstringPublic identifier
webhook_idstringPublic ID of the webhook this delivery belongs to
event_typestringThe event type that triggered the delivery
payloadobjectThe event payload that was sent
statuspending | success | failedDelivery outcome
status_codenumber | nullHTTP response status code
attemptsnumberNumber of delivery attempts made
last_attempt_atstring | nullTimestamp of the most recent attempt
next_attempt_atstring | nullWhen the next attempt becomes due; null once the delivery succeeded or exhausted its attempts
response_bodystring | nullResponse body returned by the receiver
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

Key Concepts

Event Patterns

Each webhook subscribes to one or more event patterns using dot-separated hierarchy:

PatternMatches
files.createdExactly the files.created event
files.*Any event starting with files.
*Every event in the project

Every event SOAT emits is listed in the Webhook Events Reference, which is generated from the server's event registry — a name that is not there is one no subscription will ever match.

See it end to end in Chat with an LLM - Step 9 (Create a session webhook subscription).

Delivery

When an event matches a webhook, the server sends an HTTP POST to the webhook URL. The request includes these headers:

HeaderDescription
X-Soat-EventThe event type (e.g., files.created)
X-Soat-DeliveryUnique delivery ID
X-Soat-Signature-V2Timestamped signature, t=<unix>,v1=<hex> — see Signature verification
X-Soat-SignatureDeprecated. HMAC-SHA256 hex digest of the bare request body, as sha256=<hex>

Deliveries are retried up to three times. Each attempt and its outcome are recorded in a delivery log queryable through the API. To watch a real delivery arrive and inspect its outcome, see Chat with an LLM - Step 11 (Verify delivery).

Before pointing url at a real endpoint, use soat listen to receive and inspect deliveries on your local machine.

Delivery durability

A delivery is a database row, not an in-flight function call. The row is written — with its payload and the time its next attempt is due — before the first HTTP request is made, and every attempt after the first is claimed from that row by a background sweep.

Two consequences matter to a subscriber:

  • A restart does not lose a delivery. If the server is killed between attempts, or during one, the row keeps its pending status and is picked up again once the crashed process's lease expires (about a minute). Nothing depends on the process that emitted the event still being alive.
  • Retries are spaced, not immediate. A failed attempt schedules the next one behind an exponential backoff with jitter (roughly 1s, then 2s), rather than firing three times back to back. next_attempt_at on the delivery tells you when the next one is due.

After three failed attempts the delivery is marked failed and is not retried automatically. Use redelivery to send it again.

Where the guarantee starts. Everything above holds from the moment the row exists. Getting there is a short, in-memory step: once the write that produced the event has committed, the server matches the project's subscriptions and inserts the delivery rows. A database blip during that step is retried, and a failure that outlives the retries is counted and printed to stderr rather than discarded quietly — but a process killed inside that window loses the event, and no redelivery can recover what was never recorded. The window is sub-second and unaffected by your endpoint being slow or down; closing it entirely requires the delivery row to be written in the same transaction as the change that triggered it.

Redelivery

POST /api/v1/webhook-deliveries/{delivery_id}/redeliver queues a stored payload to be sent again — useful when your endpoint was down, or when you have fixed a bug and want the original event back.

It creates a new delivery record rather than resetting the original, so the failed attempt stays in the history. The call returns 202 Accepted with the new delivery; the send itself happens in the background, so poll that delivery's status to observe the outcome.

Event Payload

The request body is a JSON envelope wrapping the resource payload. Like every other SOAT surface, it is snake_case:

FieldTypeDescription
eventstringEvent type, e.g. files.created
project_idstringPublic ID of the project the event belongs to
resource_typestringType of the resource that changed, e.g. file
resource_idstringPublic ID of the resource that changed
dataobjectThe resource payload, in the same shape the REST API returns it
timestampstringISO 8601 timestamp of the event
{
"event": "files.created",
"project_id": "proj_a1b2c3d4",
"resource_type": "file",
"resource_id": "file_e5f6g7h8",
"data": {
"id": "file_e5f6g7h8",
"project_id": "proj_a1b2c3d4",
"filename": "report.pdf"
},
"timestamp": "2026-01-31T12:00:00.000Z"
}

data is carried through verbatim from the same mapper the REST API uses, so a subscriber never needs a follow-up GET to read the resource, and no key inside it is rewritten.

Secret and Signature Verification

Every webhook has a secret generated at creation time. The secret is returned in the response body on create or secret rotation. You can also retrieve it explicitly via GET /api/v1/webhooks/{webhook_id}/secret (requires webhooks:GetWebhookSecret).

The secret is stored encrypted at rest using the same AES-256-GCM encryption as secrets, keyed by SECRETS_ENCRYPTION_KEY. It is decrypted only to sign outbound deliveries or to return it through the API to a caller with webhooks:GetWebhookSecret. See Configuration for the operational impact of losing this key.

A stored secret that is not valid ciphertext — encrypted under a SECRETS_ENCRYPTION_KEY that has since changed — is refused rather than guessed at. GET .../secret answers 500 SECRET_NOT_DECRYPTABLE, and an outbound delivery is recorded as failed with the reason and attempts: 0 rather than being sent unsigned. Rotate the secret to replace it, or restore the original key.

Verifying X-Soat-Signature-V2

The header carries two comma-separated elements: t, the Unix timestamp (in seconds) at which the attempt was signed, and v1, the HMAC-SHA256 hex digest of <t>.<raw body>.

X-Soat-Signature-V2: t=1769865600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Signing the timestamp along with the body is what bounds a replay: an attacker who captures a delivery cannot resend it later, because you reject a timestamp outside your tolerance window. Verify the digest before trusting anything in the body, and compare with a constant-time function.

const crypto = require('crypto');

const TOLERANCE_SECONDS = 300;

// `body` must be the raw request body, exactly as received — parsing and
// re-serializing it changes the bytes and the digest will not match.
const isValid = (secret, body, header) => {
const elements = Object.fromEntries(
header.split(',').map((part) => part.split('='))
);
const { t, v1 } = elements;
if (!t || !v1) return false;

// Reject anything too old (a replay) or too far in the future (a skewed clock).
if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;

const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${body}`)
.digest('hex');

const received = Buffer.from(v1, 'hex');
const computed = Buffer.from(expected, 'hex');
if (received.length !== computed.length) return false;
return crypto.timingSafeEqual(computed, received);
};

Each attempt is signed at the moment it is sent, so a retry carries its own fresh timestamp and passes the same tolerance check as a first attempt.

The deprecated X-Soat-Signature

X-Soat-Signature: sha256=<hex> signs the bare body with no timestamp, so it cannot distinguish a live delivery from one replayed days later. It is still sent alongside the new header during the deprecation window so existing subscribers keep working. Migrate to X-Soat-Signature-V2 and stop reading the old header.

// Deprecated — no replay bound.
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
const isValid = `sha256=${expected}` === header;

Policy Gating

Attach a policy to a webhook to filter deliveries without changing your event subscriptions. Policies are global resources (not scoped to any project); when one is set on a webhook, the event is only delivered if the policy evaluates to allow for the event context.

Formation Support

Webhooks can be created as part of a Formation. The webhook secret can be captured as a formation output using a ref_attr expression:

{
"resources": {
"MyWebhook": {
"type": "webhook",
"properties": {
"name": "my-hook",
"url": "https://example.com/hook",
"events": ["*"]
}
}
},
"outputs": {
"webhookId": { "ref": "MyWebhook" },
"webhookSecret": { "ref_attr": "MyWebhook.secret" }
}
}

Examples

Create a webhook

soat create-webhook \
--project-id proj_ABC \
--name "My Webhook" \
--url https://example.com/hook \
--events "sessions.*"

List webhooks

soat list-webhooks --project-id proj_ABC

Redeliver a failed delivery

soat redeliver-webhook-delivery --delivery-id wh_deliv_ABC