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 or with your API key.
Managing endpoints
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), 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:
{ "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. 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 (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 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. 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
{
"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:
{
"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:
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.
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);
});
}