---
title: Webhooks
description: Subscribe to events, verify Standard-Webhooks signatures, replay deliveries.
---

# Webhooks

Webhooks push a signed JSON payload to your HTTPS endpoint when an
account event fires. Signing follows the
[Standard Webhooks](https://www.standardwebhooks.com/) spec.

> Webhook endpoints are managed with a **user session JWT** (owner or
> admin), not an API key — they carry the `webhooks:read` /
> `webhooks:write` scopes and are also accessible from the dashboard.

---

## Registering an endpoint

Register an HTTPS endpoint in your dashboard (**Settings → Webhooks**), or
programmatically with the `webhook-endpoints` API using an owner/admin
session. You choose which event types to subscribe to, and you receive the
endpoint's **signing secret exactly once** (`whsec_…`) at creation — store
it in your secret manager, as we cannot recover it.

URL safety: we reject endpoints on private/loopback IPs (10/8, 172.16/12,
192.168/16, 127/8, ::1), `file://` URLs, and the cloud-metadata address
(169.254.169.254). HTTPS is required outside local development. The URL
is re-validated at delivery time to defeat DNS-rebinding.

From there you can list endpoints, rotate the signing secret, send a test
event, browse delivery history, and replay failed deliveries.

---

## Event envelope

The POST body is:

```json
{
  "id": "a1b2c3d4-…",
  "type": "wallet.low_balance",
  "created_at": "2026-05-28T14:23:00Z",
  "data": { "...": "event-specific payload" }
}
```

- `id` — unique event id. The same event re-delivered (retry or replay)
  keeps this `id` — **dedupe on it**.
- `type` — the event type (branch on this).
- `created_at` — ISO-8601 timestamp.
- `data` — the event-specific payload.

---

## Event catalog

Events currently delivered to customer endpoints:

| Event | When it fires |
|---|---|
| `wallet.credited` | The wallet was credited (top-up, refund) |
| `wallet.debited` | The wallet was debited (a charge was captured) |
| `wallet.low_balance` | Balance dropped below the configured threshold |
| `extraction.budget_capped` | A period's deep-extraction budget cap was reached; top up or raise the cap. Payload: `env_id`, `spent_cents_to_date`, `cap_cents` |
| `endpoint.test` | Sent by the `/test` endpoint (see below) |

> The catalog is expanding. Subscribe only to the event types you need;
> unknown future types won't be delivered unless you subscribe to them.

---

## Verifying signatures

Every delivery carries the Standard-Webhooks headers:

```http
webhook-id:        a1b2c3d4-…
webhook-timestamp: 1716508800
webhook-signature: v1,K5oP…base64…==
```

To verify, recompute the HMAC-SHA256 over
`{webhook-id}.{webhook-timestamp}.{raw_body}` using your endpoint
secret, base64-encode it, prefix `v1,`, and compare constant-time
against (any of) the space-separated signatures in `webhook-signature`.
Reject if the timestamp is too old.

<CodeTabs>

```python title="Python"
import base64, hmac, hashlib, time, json, os
from fastapi import FastAPI, Request, HTTPException

SECRET = os.environ["ARCNM_WEBHOOK_SECRET"]      # whsec_…
TOLERANCE_S = 300
app = FastAPI()

def _key(secret: str) -> bytes:
    raw = secret[len("whsec_"):] if secret.startswith("whsec_") else secret
    return base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))

def verify(raw_body: bytes, headers) -> dict:
    wid = headers["webhook-id"]
    ts  = headers["webhook-timestamp"]
    sigs = headers["webhook-signature"].split(" ")
    if abs(time.time() - int(ts)) > TOLERANCE_S:
        raise HTTPException(400, "stale_signature")
    signed = f"{wid}.{ts}.".encode() + raw_body
    mac = base64.b64encode(hmac.new(_key(SECRET), signed, hashlib.sha256).digest()).decode()
    expected = f"v1,{mac}"
    if not any(hmac.compare_digest(expected, s) for s in sigs):
        raise HTTPException(400, "bad_signature")
    return json.loads(raw_body)

@app.post("/arcanum")
async def hook(req: Request):
    event = verify(await req.body(), req.headers)
    # …handle by event["type"]
    return {"received": event["id"]}
```

```typescript title="TypeScript"
import { createHmac, timingSafeEqual } from "node:crypto"
import express from "express"

const SECRET = process.env.ARCNM_WEBHOOK_SECRET!   // whsec_…
const TOLERANCE_S = 300
const app = express()
app.use(express.raw({ type: "application/json" }))   // keep raw bytes

function key(secret: string) {
  const raw = secret.startsWith("whsec_") ? secret.slice(6) : secret
  return Buffer.from(raw, "base64url")
}

function verify(rawBody: Buffer, headers: Record<string, string>) {
  const wid = headers["webhook-id"]
  const ts = headers["webhook-timestamp"]
  if (Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_S) throw new Error("stale_signature")
  const signed = Buffer.concat([Buffer.from(`${wid}.${ts}.`), rawBody])
  const mac = createHmac("sha256", key(SECRET)).update(signed).digest("base64")
  const expected = `v1,${mac}`
  const ok = headers["webhook-signature"].split(" ").some(
    (s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
  )
  if (!ok) throw new Error("bad_signature")
}

app.post("/arcanum", (req, res) => {
  verify(req.body as Buffer, req.headers as Record<string, string>)
  const event = JSON.parse((req.body as Buffer).toString())
  res.json({ received: event.id })
})
```

</CodeTabs>

---

## Retry & failure handling

- A `2xx` response means the delivery succeeded.
- Any other status (or a timeout, ~10s) is retried with jittered
  backoff at roughly: **+5s, +5m, +30m, +2h, +5h, +10h, +24h** (an
  initial attempt plus 7 retries).
- An endpoint is auto-disabled after **20 consecutive failures**, or if
  its failure rate exceeds 50% over a 2-hour window (with enough
  samples). We email the org owner.
- Re-enable a disabled endpoint from the dashboard.

Replay a failed delivery from the dashboard — it keeps the original event
`id`, so your dedupe still works.

---

## Test events

Send a synthetic `endpoint.test` event to a single endpoint from the
dashboard. Its `data` carries a short `message` and the `endpoint_id` — use
it to bootstrap your handler before real events fire.

---

## See also

- [Errors](./errors.md) — verification failures, replay errors.
- [Idempotency](./idempotency.md) — dedupe on the event `id`.
- [Authentication](./authentication.md) — `webhooks:read` / `webhooks:write`.
