Building blocks
Webhooks
Subscribe to events, verify Standard-Webhooks signatures, replay deliveries.
Webhooks push a signed JSON payload to your HTTPS endpoint when an account event fires. Signing follows the Standard Webhooks spec.
Webhook endpoints are managed with a user session JWT (owner or admin), not an API key — they carry the
webhooks:read/webhooks:writescopes 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:
{
"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 thisid— 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:
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.
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"]}
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 })
})
Retry & failure handling
- A
2xxresponse 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 — verification failures, replay errors.
- Idempotency — dedupe on the event
id. - Authentication —
webhooks:read/webhooks:write.