guides
Intended Documentation
Webhooks
Get a signed HTTP POST when a decision is made, escalated, approved, rejected, canceled or expires, or when an agent drifts from its baseline. Event list, payloads, signature verification and retries.
Webhooks#
Intended can send your service a signed HTTP POST when something happens to a decision. Your agents do not need webhooks to get their answer: every Intended client waits for the approver for you (see What your agent gets back). Use webhooks when another system has to react, for example to post to a chat channel when an action is escalated, or to close a ticket when it is approved.
Add an endpoint#
In the console, open Settings › Webhooks, add your HTTPS URL and pick the events to send. The signing secret is shown once, when you create the endpoint. Store it in your secret manager. URLs that point at private, loopback or link-local addresses are refused.
Events#
| Event | Sent when |
|---|---|
authority.decision.created | Intended makes any first decision (ALLOW, DENY or ESCALATE) |
decision.escalated | An action is escalated and enters the review queue |
decision.approved | An approver approves an escalation |
decision.rejected | An approver rejects an escalation |
decision.expired | Nobody decided before the escalation expired |
decision.canceled | The escalation was canceled before anyone decided |
agent.drift_detected | An agent's behaviour moves outside its usual pattern |
An escalation ends in exactly one of approved, rejected, canceled or expired. The webhook for that ending is queued in the same database transaction that records it, so if the ending is recorded, its webhook is queued too. decision.escalated is queued just after the escalation is created, outside that transaction. It is sent in almost every case but is not guaranteed, so do not rely on it to learn that an escalation exists. Expiry is recorded on schedule, within about a minute of the deadline, whether or not an agent is waiting.
Escalation payloads#
The five decision.* events share one shape:
| Event | status | decidedBy |
|---|---|---|
decision.escalated | PENDING | null. The body also has expiresAt, when the escalation stops being decidable |
decision.approved | ALLOW | { "type": "human", "role": "<console role>" } |
decision.rejected | DENY | { "type": "human", "role": "<console role>" } |
decision.expired | EXPIRED | { "type": "system", "role": null } |
decision.canceled | DENY | { "type": "human", "role": "<console role>" } |
status is the same final answer the agent gets from GET /v1/decisions/{intentId}/wait.
These payloads never include the authority token, the approver's name or email, the rejection reason or the action's arguments. To see those, look the intent up in the console or the API with intentId. The agent collects the token itself; a webhook can never be used to act.
Verify the signature#
Every delivery carries these headers:
| Header | Value |
|---|---|
x-intended-event | The event type |
x-intended-delivery | A unique delivery id. Retries reuse it, so use it to drop duplicates |
x-intended-signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
The signature is the HMAC-SHA256, keyed with your endpoint's secret, of <t>.<raw request body>. Check it against the raw body before you parse it, and reject old timestamps:
Delivery and retries#
Answer with any 2xx within 15 seconds. Anything else is retried up to 6 attempts in total, after 30 seconds, 5 minutes, 30 minutes, 2 hours and 12 hours. A queued webhook can arrive more than once, so handle the same x-intended-delivery id twice safely. After the sixth failed attempt that delivery is dropped. Deliveries are not guaranteed to arrive in order: use occurredAt to order the events of one escalation.
If five deliveries in a row fail every attempt, the endpoint is paused and the console shows it. Fix the receiver, then resume the endpoint in Settings › Webhooks. You can also send a test event and retry a failed delivery from there.