# Webhooks

Instead of polling, register an HTTPS endpoint in the
[dashboard](/dashboard/webhooks) and OpenIntents will `POST` an event every
time an intent changes status.

## Events

| Event | Sent when |
| --- | --- |
| `intent.running` | A browser agent has started. |
| `intent.requires_payment` | Paused at checkout; `data.payment.payment_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. |

## 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": 28, "currency": "usd" }
  }
}
```

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}`. 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 parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, "hex"));
}
```
