Accurendocs
Access dApp

API

Webhooks

Signed, retried, and idempotent — the delivery path for anything that cannot wait for a poll.

bash
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.

delivery body
{
  "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
  }
}
PolicyValue
Attempts per delivery, including the first3
Replay window you should enforce5 minutes
Consecutive failures before an endpoint is disabled10
DeduplicationAccuren-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.

verify.ts
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.

⌘I