ARCNM

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: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:

{
  "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 iddedupe 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 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