Browse docs
previewThese docs describe OpenIntents as it is being built. Endpoints and payloads may change before launch.

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

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"]}'
RequestDoes
POST /v1/webhook_endpointsCreates an endpoint from url and events (empty or left out: every intent event). Honours Idempotency-Key.
GET /v1/webhook_endpointsLists 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_secretMakes a new signing secret. The old one keeps signing for 24 hours.
POST /v1/webhook_endpoints/{id}/testSends a webhook_endpoint.test event to this endpoint, whatever it subscribes to.
GET /v1/webhook_endpoints/{id}/deliveriesThe endpoint's delivery log, newest first, with limit and cursor.
POST /v1/webhook_endpoints/{id}/deliveries/{delivery}/redeliverSends 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:

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. Your account's endpoints still get their events as before.

Events

EventSent when
intent.runningA browser agent has started.
intent.requires_paymentPaused 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_inputPaused until your customer answers what the agent needs to sign them in; data.input_request is set.
intent.requires_loginPaused until your customer signs in to the shop; data.sign_in.url is set.
intent.completedPaid and confirmed; data.result is set.
intent.failedThe intent could not be completed.
intent.cancelledThe intent was cancelled or its payment link expired.
session.openedA browser session is open for commands.
session.closedA session ended: released, idle or at its time limit.
session.failedA session's browser stopped.
secret_request.completedA payer filled in a secret request. data.secrets lists the handles it made, never a value. Sent only to endpoints that name it.
wallet.connectedA 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);
    });
}