Webhooks

Event payloads, signing, and the retry policy.

Configuration

Set the webhook URL + secret at /m/settings. The secret is encrypted at rest with AES-GCM; we never log or display it after creation.

Verifying deliveries (V2)

Every delivery is signed with X-Arcora-Signature-V2 paired with X-Arcora-Timestamp (unix seconds): sha256=HMAC-SHA256("<timestamp>." + rawBody, secret). Binding the timestamp into the signed payload gives you replay protection: reject any delivery whose timestamp is outside a tolerance window (we use ±300 seconds). This is the verification method — use it for all integrations.

The SDK (≥ 1.3.0) ships an official verifier at the @arcora/sdk/webhook subpath. It enforces the replay window and compares in constant time. Server-side only (uses node:crypto):

import { verifyWebhook } from "@arcora/sdk/webhook";

const ok = verifyWebhook({
  body: rawBody,                                   // raw string, not re-stringified JSON
  signature: req.headers["x-arcora-signature-v2"],
  timestamp: req.headers["x-arcora-timestamp"],
  secret: process.env.ARCORA_WEBHOOK_SECRET,
});

The header value carries a sha256= prefix followed by the lowercase hex HMAC — the prefix is part of the header value, so compare against the whole string, do not strip it before you have a constant-time match.

Verifying without the SDK

The same algorithm works in any runtime with an HMAC-SHA256 primitive and a constant-time compare:

import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300; // ±5 min replay window

function verifyWebhookV2(headers: Headers, rawBody: string, secret: string) {
  const sig = headers.get('x-arcora-signature-v2') ?? '';
  const ts  = headers.get('x-arcora-timestamp') ?? '';

  const tsNum = Number(ts);
  if (!Number.isFinite(tsNum) || Math.abs(Date.now() / 1000 - tsNum) > TOLERANCE_SECONDS) {
    return false; // outside the replay window
  }

  const expected = 'sha256=' + createHmac('sha256', secret).update(ts + '.' + rawBody).digest('hex');
  const ab = Buffer.from(expected);
  const bb = Buffer.from(sig);
  return ab.length === bb.length && timingSafeEqual(ab, bb);
}

Legacy V1 signature (deprecated)

Deprecated — do not build new integrations on V1. The legacy X-Arcora-Signature header (sha256=HMAC-SHA256(rawBody, secret)) has no timestamp, so a captured V1 delivery can be replayed indefinitely. It is kept only for existing receivers and will be removed at mainnet launch. Migrate to X-Arcora-Signature-V2 now.

Deliveries still carry the V1 header alongside V2 during the deprecation window, plus two signal headers: Deprecation: version=1 and a Link: <https://arcorapay.xyz/docs/webhooks#v2>; rel="deprecation" header pointing at this section. When both signatures are present, always verify V2 and ignore V1.

Event types

invoice.paid

Fires when the indexer sees InvoicePaid on-chain.

{
  "event_id":   "8a7e1c2b-...",
  "type":       "invoice.paid",
  "invoice_id": "0x4f3a...",
  "paid_by":    "0x3687...",
  "tx_hash":    "0x9f...",
  "metadata":   { "orderId": "123" }
}

invoice.refunded

Fires when the indexer sees InvoiceRefunded on-chain.

{
  "event_id":    "...",
  "type":        "invoice.refunded",
  "invoice_id":  "0x...",
  "refunded_to": "0x...",
  "tx_hash":     "0x..."
}

compliance.review_queued

Fires when /api/checkout/authorize returns review for a customer wallet (Plan-5). Merchant is notified out-of-band so they can follow up with the buyer if they want to.

{
  "event_id":   "...",
  "type":       "compliance.review_queued",
  "invoice_id": "0x...",
  "payer":      "0x...",
  "ticket_id":  "rev_..."
}

Retry policy

Failed deliveries (non-2xx, network error, timeout) are retried with exponential backoff. After each failed attempt the next retry is scheduled 2^attempts × 30 seconds out — so roughly 1 min, then 2, 4, 8, 16 min and so on, doubling each time. The interval is capped at 24 hours, after which deliveries keep retrying at that 24h cadence.

The two failure classes are treated differently:

  • 5xx responses, network errors, and timeouts are retried indefinitely with the growing backoff above. Only a successful (2xx) delivery stops them.
  • 4xx responses are treated as terminal after 3 such failed attempts — the delivery is marked terminal and never re-queued. (A 3xx redirect is also a delivery failure: redirects are never followed.)

If your endpoint is down (returning 5xx, refusing connections, or timing out), deliveries are notdropped — they keep retrying with the growing backoff, up to 24h intervals, until your endpoint recovers and returns a 2xx. If instead your endpoint is rejecting deliveries with a 4xx (bad signature handling, wrong route, auth failure), fix your endpoint: after 3 such 4xx failures the delivery becomes terminal and will not be retried. You can always reconcile state by querying invoice status via the API directly.

Idempotency

event_id is a UUID generated server-side at enqueue time. If you receive the same event_id twice (rare — usually only happens if your endpoint times out but eventually returns 2xx), treat it as idempotent.