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.
Manage webhook endpoints from the dashboard (Settings → Connected apps / Webhooks) with an owner/admin session, or programmatically with any credential carrying the
webhooks:writescope — an API key or an OAuth access token. That makes subscribe/unsubscribe usable as a REST Hooks trigger from iPaaS platforms (Zapier, Make, n8n).
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.
Subscribe programmatically (the REST-Hooks pattern) with a scoped key:
curl -X POST https://api.arcnm.io/api/v1/webhook-endpoints \
-H "X-API-Key: ak_live_…" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.zapier.com/…","event_types":["calculation.succeeded"]}'
# → 201 { "id": "…", "secret": "whsec_…" } ← store the secret now
The subscribable event types are discoverable at
GET /api/v1/webhook-endpoints/event-types (requires webhooks:read) — the
machine-readable mirror of the catalog below. Unknown event types are
rejected at subscribe time with 400, so a typo fails fast.
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 |
|---|---|
calculation.succeeded |
A part calculation finished with a priced result. Payload: calculation_id, part_id, part_revision_id, unit_cost, currency |
calculation.failed |
A part calculation terminated with an error (its engine hold is refunded). Payload: calculation_id, error |
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 |
billing.prepaid_funding_needed |
Your organization is close to its included calculations with an empty balance. Top up before it runs out — past the included volume, calculations are saved but park as blocked until funded. Payload: tier_slug, used, included, remaining, overage_cents, wallet_balance_cents |
billing.spend_cap_warning |
Monthly overage spend crossed 50% or 80% of a spend cap. Payload: scope (org or api_key), holder_id, threshold_pct, cap_cents, spend_cents, action |
billing.spend_cap_reached |
Monthly overage spend reached a spend cap. With action: "pause" further calculations park as blocked until the cap is raised or the month rolls over. Same payload as the warning |
extraction.budget_capped |
A drawing extraction hit its costing environment's monthly AI budget and fell back to the bundled model for the rest of the month. Payload: env_id, cap_cents, spent_cents |
endpoint.test |
Sent by the /test endpoint (see below) |
A multi-environment batch emits one calculation.succeeded /
calculation.failed per cell that runs — cells answered from the
dedup cache or parked blocked emit nothing — and there is
deliberately no aggregate batch event; poll
GET /calculations/batches/{batch_id}/comparison
and read its complete flag to know when the whole grid has landed.
You can subscribe to a whole namespace with a wildcard (calculation.*,
wallet.*) or to everything with *. Event-type names are a stable,
versioned contract (since v1) — we add new names rather than repurpose
existing ones.
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.