> ## Documentation Index
> Fetch the complete documentation index at: https://alyte.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Signed payment.settled push to your server — polling becomes the fallback

When an agent's purchase settles, Alyte can push a signed `payment.settled` event to your server the moment it happens. Polling `GET /v1/integration/payments?since=…` keeps working and remains the documented catch-up path — webhooks are an upgrade, not a replacement. Keep the poller running at a relaxed cadence as your safety net.

## Register an endpoint

```bash theme={null}
curl -X POST https://<alyte-host>/v1/integration/webhooks \
  -H "Authorization: Bearer $ALYTE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://yourshop.example/api/alyte/webhook" }'
```

```json theme={null}
{
  "webhook": { "id": "whe_1a2b3c4d5e6f", "url": "https://yourshop.example/api/alyte/webhook", "status": "active" },
  "secret": "whsec_…"
}
```

<Warning>
  The `secret` is shown **only in this response**. Store it like a password (an environment secret, not your database in plaintext). Listing endpoints never returns it; if you lose it, delete the endpoint and register a new one.
</Warning>

URLs must be `https` (localhost is allowed for development). Each tenant can have at most **3 active endpoints**. `GET /v1/integration/webhooks` lists yours; `DELETE /v1/integration/webhooks/:id` disables one — delivery stops immediately.

## The delivery

Each delivery is a `POST` with three headers and a JSON body:

```http theme={null}
POST /api/alyte/webhook HTTP/1.1
alyte-event: payment.settled
alyte-delivery: whd_9f8e7d6c5b4a
alyte-signature: t=1757600000,v1=6c8a…e2f1
Content-Type: application/json
```

```json theme={null}
{
  "id": "whd_9f8e7d6c5b4a",
  "type": "payment.settled",
  "eventId": "pi_4336fd3a21b04c…",
  "createdAt": "2026-09-11T10:45:00.000Z",
  "data": {
    "intentId": "pi_4336fd3a21b04c…",
    "merchantId": "mrc_39396bf1-6",
    "amountMinor": 14900,
    "currency": "EUR",
    "status": "authorized",
    "pspId": "stripe",
    "scheme": "visa",
    "source": "buyer-ui",
    "createdBy": "agt_0582d773…",
    "createdAt": "2026-09-11T10:44:59.000Z",
    "tierId": "tier_baa5722d-8",
    "quantity": 1,
    "buyerRef": "byr_7d31c2…"
  }
}
```

`data` is the **same payment row shape** your poller reads from `/v1/integration/payments` — one parser serves both paths. Fulfil deterministically on **`tierId` + `quantity`** (never by amount); `buyerRef` is a stable opaque buyer id, never an email.

## `watch.failed` — when an agent tried and could not buy

A queued purchase (buy-at-the-drop or join-the-line) that fires and is **refused**
is terminal: it does not retry, and without a signal the buyer would never learn
what happened. So every terminal failure pushes `watch.failed` through the same
signed pipeline:

```json theme={null}
{
  "type": "watch.failed",
  "eventId": "watchfail_wch_ab4ca107e83",
  "data": {
    "watchId": "wch_ab4ca107e83",
    "agentId": "agt_43563ce6-2",
    "buyerRef": "byr_edcbac9a…",
    "merchantId": "mrc_39396bf1-6",
    "tierId": "tier_baa5722d-8",
    "quantity": 3,
    "reason": { "code": "spend_cap_exceeded", "message": "would exceed spend cap: 0.00 spent + 561.00 > cap 200.00 EUR" },
    "failedAt": "2026-09-16T20:09:21.000Z"
  }
}
```

Common `reason.code` values: `spend_cap_exceeded`, `mandate_invalid` (a quantity
guardrail or scope rule), `not_found` (the saved card is no longer usable),
`sale_ended`, `payment_refused`, `sca_required`. **Relay the message to the
buyer** — it is written to be shown, and it tells them what to change (raise the
limit, buy fewer, re-add the card).

<Note>
  `watch.failed` is **not** a payment: nothing was charged and nothing must be
  fulfilled. Branch on `type` — fulfil only `payment.settled`.
</Note>

## Verify the signature

The signature is an HMAC-SHA256 over the **raw request body**, prefixed with the timestamp — the same shape as Stripe's, so an existing verifier ports directly:

```
alyte-signature: t=<unix-seconds>,v1=hex( hmac_sha256( secret, "<t>.<raw body>" ) )
```

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

function verifyAlyteSignature(rawBody: string, header: string, secret: string, toleranceSec = 300): boolean {
  const t = /t=(\d+)/.exec(header)?.[1];
  const v1 = /v1=([0-9a-f]+)/.exec(header)?.[1];
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false; // replay window
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
```

<Note>
  Verify against the **raw** body bytes, before any JSON parsing or re-serialization — a re-stringified body will not match. Most frameworks need a raw-body route or content-type parser for the webhook path.
</Note>

## Test your endpoint before a real purchase

```bash theme={null}
curl -X POST https://<alyte-host>/v1/integration/webhooks/whe_1a2b3c4d5e6f/test \
  -H "Authorization: Bearer $ALYTE_API_TOKEN"
```

Sends a real, signed delivery through the same pipeline — so verifying it proves your signature check and raw-body handling work before any money is involved.

<Warning>
  The test event's type is **`webhook.test`**, not `payment.settled`, and it carries no payment fields. Your fulfilment path must branch on `type` and ignore anything that is not `payment.settled` — never issue goods on a test.
</Warning>

## Rotate a leaked secret

If a signing secret is exposed (a log, a screenshot, a shared terminal), rotate it rather than deleting and re-registering — the endpoint id and any queued retries survive:

```bash theme={null}
curl -X POST https://<alyte-host>/v1/integration/webhooks/whe_1a2b3c4d5e6f/rotate-secret \
  -H "Authorization: Bearer $ALYTE_API_TOKEN"
```

Returns a new `whsec_…` **once**. The old secret stops verifying immediately, so deploy the new one promptly; deliveries attempted in between fail signature verification on your side and are retried, arriving correctly once the new secret is live.

## Delivery semantics

* **At-least-once.** A settle durably enqueues the event, then delivery is attempted immediately and retried with exponential backoff (30s doubling, capped at 1h, up to 10 attempts) on the sweep tick. Any non-2xx response, timeout, or connection failure counts as a failed attempt.
* **Dedupe by `eventId`.** Retries and edge cases can deliver the same event twice. `eventId` is the payment's `intentId` — process each `eventId` once (the same discipline your poller already uses).
* **Answer fast.** Respond `2xx` as soon as you've recorded the event; do your fulfilment work asynchronously. Slow handlers risk the 10-second delivery timeout and a redundant retry.
* **Disabled means stopped.** Deleting an endpoint stops deliveries immediately, including queued retries.

## Keep the poller as the fallback

Webhooks tell you *now*; the poller guarantees *eventually*. The recommended integration:

1. Webhook receiver verifies, records by `eventId`, fulfils.
2. Poller runs every few minutes with `?since=<your newest createdAt>`, fulfilling anything the webhook path missed — deduped by the same `intentId` key.

That pair gives you real-time fulfilment with no single point of failure.
