API
Webhooks
Signed, retried, and idempotent — the delivery path for anything that cannot wait for a poll.
curl -X POST https://api.accuren.xyz/v1/webhooks \
-H "Authorization: Bearer $ACCUREN_API_KEY" \
-d '{
"url": "https://example.com/hooks/accuren",
"description": "prod receiver",
"kinds": ["accrued-income", "token-paused"]
}'The field is `kinds`, not `events`
kinds takes any of accrued-income, token-paused, oracle-paused, pending-multiplier, unlabeled-transfers, stale-reference, degraded-read. Empty means all of them. An unknown kind is refused with 400 unknown_alert_kind rather than dropped — a request that silently subscribed you to less than you asked for would show up only as events that never arrive.
The secret comes back once
201 carries the endpoint plus its secret, and that is the only response that ever will — it cannot be stored as a hash, because a hash cannot sign. Copy it then, or delete the endpoint and make another.
{
"id": "evt_a19c",
"type": "alert",
"createdAt": 1756512000000,
"data": {
"kind": "accrued-income",
"severity": "warning",
"title": "Dividend income with no wallet transaction",
"detail": "A multiplier has grown since you acquired the position.",
"symbol": "NVDA",
"at": 1756511400000
}
}| Policy | Value |
|---|---|
| Attempts per delivery, including the first | 3 |
| Replay window you should enforce | 5 minutes |
| Consecutive failures before an endpoint is disabled | 10 |
| Deduplication | Accuren-Event-Id, stable per alert per endpoint across retries |
#Verifying a delivery
Every request carries two headers: Accuren-Event-Id, and Accuren-Signature in the form t=<unix seconds>,v1=<hex>. There is no separate timestamp header — the timestamp travels inside the signature and is signed along with the body, which is exactly what lets you refuse an old request without trusting an unsigned value. Compute HMAC-SHA256 over ` ${t}.${rawBody} ` and compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(req: { body: string; headers: Record<string, string> }, secret: string) {
// "t=1756512000,v1=9f21…" — both halves live in the one header.
const header = req.headers["accuren-signature"];
if (!header) return false;
const parts = new Map(header.split(",").map((p) => p.split("=") as [string, string]));
const ts = parts.get("t");
const sig = parts.get("v1");
if (!ts || !sig) return false;
// Five minutes. Without this a captured request stays replayable forever,
// because a signature has no expiry of its own.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${ts}.${req.body}`).digest();
const given = Buffer.from(sig, "hex");
return expected.length === given.length && timingSafeEqual(expected, given);
}Verify before you parse
An unverified webhook body is untrusted input. Check the signature first, then parse.
#Retries and duplicates
- Three attempts, including the first — not an open-ended backoff. A 4xx other than 429 is not retried, because the answer will not change.
- Every delivery carries an
id, stable per alert per endpoint across retries; the same event may arrive twice, so key your handler on it. - Respond 2xx as soon as you have stored the event. Do the work afterwards.
- Ten consecutive failures disable the endpoint. It is disabled rather than deleted — the row and its delivery log stay, so you can see what happened and switch it back on.
Check what was attempted
GET /v1/webhooks/deliveries returns the last 50 attempts with the outcome, response status, error, attempt count, and duration of each — the same log the dApp's Webhooks page renders.