# Webhooks

Instead of polling, register an HTTPS endpoint and OpenIntents will `POST` an
event every time an intent changes status. Endpoints belong to your account:
manage them in the [dashboard](/dashboard/webhooks) or with your API key.

## Managing endpoints

```bash
curl https://api.openintents.io/v1/webhook_endpoints \
  -H "Authorization: Bearer $OPENINTENTS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/openintents", "events": ["intent.completed"]}'
```

| Request | Does |
| --- | --- |
| `POST /v1/webhook_endpoints` | Creates an endpoint from `url` and `events` (empty or left out: every intent event). Honours `Idempotency-Key`. |
| `GET /v1/webhook_endpoints` | Lists your endpoints, newest first, with `limit` and `cursor`. |
| `GET /v1/webhook_endpoints/{id}` | Reads one. |
| `PATCH /v1/webhook_endpoints/{id}` | Changes `url`, `events` or `disabled`. A disabled endpoint is sent nothing. |
| `DELETE /v1/webhook_endpoints/{id}` | Deletes it and drops what it still had waiting. |
| `POST /v1/webhook_endpoints/{id}/rotate_secret` | Makes a new signing secret. The old one keeps signing for 24 hours. |
| `POST /v1/webhook_endpoints/{id}/test` | Sends a `webhook_endpoint.test` event to this endpoint, whatever it subscribes to. |
| `GET /v1/webhook_endpoints/{id}/deliveries` | The endpoint's delivery log, newest first, with `limit` and `cursor`. |
| `POST /v1/webhook_endpoints/{id}/deliveries/{delivery}/redeliver` | Sends a finished delivery again. |

The signing `secret` (`whsec_…`) is in the response that creates an endpoint
and in the one that rotates it, and is not shown again. An account can have 16
endpoints. A URL must be `https` on port 443.

### Rotating the secret

`rotate_secret` returns the new `secret` and sets
`previous_secret_expires_at` on the endpoint. Until then every request
carries a signature made with each secret (see
[below](#verifying-signatures)), so you can deploy the new secret without
dropping an event. A second rotation inside that day retires the secret before
it at once.

### The delivery log

Each item of `deliveries` is a `webhook_delivery`: its `status` (`pending`,
`delivered` or `failed`), `attempts`, the receiver's last `response_code` (or
an `error` when it did not answer), `created_at`, `delivered_at` and, while it
is still being tried, `next_attempt_at`. A redelivery is a new item with the
same `event_id` and body, and `redelivery_of` naming the one it repeats; a
delivery still being tried cannot be redelivered (`409`). A test event shows
up in the log like any other.

## One intent or session

To hear about a single intent or session without an endpoint, send a
`webhook_url` when you create it:

```json
{ "intent": "Order a flat white from Blue Bottle", "webhook_url": "https://example.com/hooks/order-42" }
```

Every event of that intent (or session) goes to that URL, and no other
object's does. The create response carries a `webhook_secret` of its own,
shown once; deliveries are signed with it as described
[below](#verifying-signatures). Your account's endpoints still get their
events as before.

## Events

| Event | Sent when |
| --- | --- |
| `intent.running` | A browser agent has started. |
| `intent.requires_payment` | Paused at checkout until the payer takes a step in their [wallet](/docs/wallets#when-a-purchase-waits) (or approves the purchase); `data.payment.reason` and `data.payment.payment_url` are set. |
| `intent.requires_input` | Paused until your customer answers what the agent needs to sign them in; `data.input_request` is set. |
| `intent.requires_login` | Paused until your customer signs in to the shop; `data.sign_in.url` is set. |
| `intent.completed` | Paid and confirmed; `data.result` is set. |
| `intent.failed` | The intent could not be completed. |
| `intent.cancelled` | The intent was cancelled or its payment link expired. |
| `session.opened` | A [browser session](/docs/sessions) is open for commands. |
| `session.closed` | A session ended: released, idle or at its time limit. |
| `session.failed` | A session's browser stopped. |
| `secret_request.completed` | A payer filled in a [secret request](/docs/vault#let-the-payer-enter-it-themselves). `data.secrets` lists the handles it made, never a value. Sent only to endpoints that name it. |
| `wallet.connected` | A customer (`data.customer`), or your account buying for itself (`null`), connected the wallet their purchases are paid from, from a wallet link, the portal or the dashboard. Sent only to endpoints that name it. |

Session events go only to endpoints subscribed to them by name: an endpoint
that takes every event keeps getting intent events only. Their `data` is the
session object, so check `type` before reading `data`.

## Payload

```json
{
  "id": "evt_91c2",
  "type": "intent.completed",
  "created_at": "2026-10-01T08:31:12Z",
  "data": {
    "id": "int_7f3a2c",
    "object": "intent",
    "status": "completed",
    "result": { "merchant": "Blue Bottle Coffee", "total": 550, "fee": 0, "currency": "usd" }
  }
}
```

A `wallet.connected` event carries the wallet instead:

```json
{
  "id": "evt_a41d",
  "type": "wallet.connected",
  "created_at": "2026-10-01T08:14:03Z",
  "data": { "object": "wallet", "customer": "cus_8f2a", "status": "connected", "livemode": true }
}
```

Respond with any `2xx` within 10 seconds. Failed deliveries are retried with
exponential backoff for 24 hours. Events can arrive more than once and out of
order: de-duplicate on `id` and trust `data.status`.

## Verifying signatures

Each request carries an `OpenIntents-Signature` header:

```text
OpenIntents-Signature: t=1790841072,v1=5f8e3a…
```

`v1` is an HMAC-SHA256, keyed with your endpoint's `whsec_` secret, of
`{t}.{raw request body}`. For 24 hours after a rotation the header holds one
`v1` per secret (`t=…,v1=…,v1=…`): accept the request if any of them matches.
Reject requests whose signature doesn't match or whose `t` is more than 5
minutes old.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody: string, header: string, secret: string): boolean {
  const pairs = header.split(",").map((p) => p.split("="));
  const t = Number(pairs.find(([k]) => k === "t")?.[1]);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return pairs
    .filter(([k]) => k === "v1")
    .some(([, v]) => {
      const given = Buffer.from(v ?? "", "hex");
      return given.length === 32 && timingSafeEqual(given, expected);
    });
}
```
