Build
Webhooks
Event types, the signature scheme, verification in Node and PHP, replay window and retries.
A webhook is how your system learns that a payment settled. Because it is also how an attacker would like to tell your system that a payment settled, the signature is not optional and the verification order matters.
Event types #
The complete set. An unknown type is rejected when the event is built, so a typo in an event name fails at our end instead of being silently ignored by your switch statement.
| Type | Meaning |
|---|---|
payment.created | An intent was created and is awaiting payment. |
payment.detected | A matching transfer was found in a block. Not settlement. |
payment.confirming | Depth is accumulating. Fires on entry to CONFIRMING, not per block. |
payment.confirmed | Required depth reached and the receipt succeeded. This is the one that means paid. |
payment.failed | The transaction executed unsuccessfully, or the intent was failed administratively. |
payment.expired | The checkout window closed unpaid. A later payment can still arrive. |
payment.underpaid | Less arrived than requested. Never auto-settles; your decision. |
payment.overpaid | More arrived than requested. Never auto-settles; your decision. |
payment.reorged | A settled payment was un-settled by a chain reorg: the transaction that paid it is no longer on the canonical chain. The intent returns to AWAITING_PAYMENT and our ledger entry is reversed. If you already ran payment_complete(), undo it and do not ship. |
refund.created | A refund was requested and a transfer is being prepared. |
refund.completed | The refund transfer confirmed with a successful receipt. |
invoice.paid | An invoice was paid in full. |
payment.confirmed is the only event that means the money is settled. Do not fulfil on payment.detected or payment.confirming.
Payload #
The envelope is {id, type, created, data}. created is unix seconds and describes when the fact happened. It is not the signed timestamp: the Animica-Timestamp header carries the time of the delivery attempt, which is what your replay window should be measured against. The two differ whenever an event waited in a queue, survived a worker restart, or was re-driven by hand — and an event that waited must still be deliverable.
{
"id": "evt_4f1c2ab9d0e3f5a7b8c9d0e1f2a3b4c5",
"type": "payment.confirmed",
"created": 1786000000,
"data": {
"payment_intent": "pay_X9m2ABC",
"merchant_order_id": "19482",
"gross_anm": "41825250000000",
"protocol_fee_anm": "836505000000",
"merchant_anm": "40988745000000",
"transaction_hash": "0x8f2c…",
"confirmations": 12,
"receipt_ok": true
}
}
The full accounting rides along deliberately, so your order screen can show gross, fee and net without a second API call.
Headers #
| Header | Example | Signed |
|---|---|---|
Animica-Signature | v1=9f8c… (comma-separated during rotation) | n/a |
Animica-Event-ID | evt_4f1c… | yes |
Animica-Timestamp | 1786000000 | yes |
Animica-Delivery-Attempt | 3 | no — a debugging aid. Never trust it. |
The signature scheme #
HMAC-SHA256 over this exact string:
v1:<unix_seconds>:<event_id>:<raw_body>
Three properties, all deliberate:
- The timestamp is inside the signed string. If it were only a header, anyone who captured one body could re-send it forever with a fresh timestamp and your replay window would be decoration.
- The event id is inside the signed string, so a captured
payment.confirmedcannot be re-labelled as another event. - Compare in constant time. An HMAC check that returns on the first differing byte is a byte-at-a-time forgery oracle.
Node #
// verify-animica-webhook.js — Node 18+, no dependencies.
const crypto = require('node:crypto');
const TOLERANCE_SEC = 300;
function verifyAnimicaWebhook({ rawBody, headers, secrets, nowSec }) {
const now = nowSec == null ? Math.floor(Date.now() / 1000) : nowSec;
const get = (name) => {
const want = name.toLowerCase();
for (const k of Object.keys(headers)) if (k.toLowerCase() === want) {
const v = headers[k];
return Array.isArray(v) ? v[0] : v;
}
return null;
};
const sigHeader = get('animica-signature');
const eventId = get('animica-event-id');
const tsRaw = get('animica-timestamp');
if (!sigHeader || !eventId || !tsRaw) return { ok: false, reason: 'missing headers' };
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(eventId)) return { ok: false, reason: 'bad event id' };
const ts = Number(tsRaw);
if (!Number.isInteger(ts) || ts <= 0) return { ok: false, reason: 'bad timestamp' };
if (Math.abs(now - ts) > TOLERANCE_SEC) return { ok: false, reason: 'stale timestamp' };
// The timestamp and the event id are INSIDE the signed string, so neither can
// be swapped by a replayer. rawBody must be the exact bytes received.
const signed = `v1:${ts}:${eventId}:${rawBody}`;
const provided = String(sigHeader).split(',')
.map((part) => part.trim().split('='))
.filter((pair) => pair[0] === 'v1' && /^[0-9a-fA-F]{64}$/.test(pair[1] || ''))
.map((pair) => pair[1].toLowerCase());
if (provided.length === 0) return { ok: false, reason: 'no v1 signature' };
for (const secret of [].concat(secrets)) {
const expected = crypto.createHmac('sha256', secret).update(signed, 'utf8').digest('hex');
for (const candidate of provided) {
const a = Buffer.from(candidate, 'hex');
const b = Buffer.from(expected, 'hex');
// Constant time: an early-exit compare hands out a forgery oracle.
if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
return { ok: true, eventId, timestamp: ts };
}
}
}
return { ok: false, reason: 'signature mismatch' };
}
module.exports = { verifyAnimicaWebhook };
PHP #
<?php
// Verify BEFORE touching an order. $raw must be the exact request body:
// $raw = file_get_contents('php://input');
function animica_verify_webhook(string $raw, array $headers, array $secrets): bool {
$tolerance = 300;
$lower = [];
foreach ($headers as $k => $v) {
$lower[strtolower(str_replace('_', '-', $k))] = is_array($v) ? $v[0] : $v;
}
$sig = $lower['animica-signature'] ?? '';
$id = $lower['animica-event-id'] ?? '';
$ts = $lower['animica-timestamp'] ?? '';
if ($sig === '' || $id === '' || $ts === '') { return false; }
if (!preg_match('/^[A-Za-z0-9_.-]{1,64}$/', $id)) { return false; }
if (!ctype_digit((string) $ts)) { return false; }
if (abs(time() - (int) $ts) > $tolerance) { return false; }
$signed = 'v1:' . (int) $ts . ':' . $id . ':' . $raw;
foreach (explode(',', $sig) as $part) {
if (strpos(trim($part), 'v1=') !== 0) { continue; }
$candidate = strtolower(substr(trim($part), 3));
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', $signed, $secret);
// hash_equals is the constant-time compare. Never use ===.
if (hash_equals($expected, $candidate)) { return true; }
}
}
return false;
}
Replay window #
Reject anything whose timestamp is more than 300 seconds (5 minutes) from now, in either direction. A future timestamp is only ever clock skew; beyond the allowance it is someone choosing their own timestamp to buy an unbounded replay window. Check the window before the HMAC, so a flood of stale captures costs an integer compare instead of a hash.
Retries #
Up to 6 attempts per event, with exponential backoff and jitter, and a 10-second timeout per attempt.
| Between attempts | Nominal delay | Jitter |
|---|---|---|
| 1 → 2 | ~1s | ±20% |
| 2 → 3 | ~2s | ±20% |
| 3 → 4 | ~4s | ±20% |
| 4 → 5 | ~8s | ±20% |
| 5 → 6 | ~16s | ±20% |
Jitter exists because retries are correlated across merchants: an outage releases every queued event at once, and without jitter every retry from every endpoint lands in the same millisecond and re-creates the outage.
What is retried, and what is not:
| Response | Treated as |
|---|---|
| 2xx | delivered |
| 408, 429, 5xx | retryable |
| other 4xx | permanent — resending identical bytes cannot change your mind |
| 3xx | permanent — redirects are not followed, because following one would replay a signed payment event at whatever host the redirect names |
| connection error, DNS, TLS, timeout | retryable |
After 5 consecutive failed deliveries an endpoint is disabled and must be re-enabled by hand, because the breaker opened for a reason. Re-enabling resets the counter.
Your handler must be idempotent #
This is a rule, not a suggestion. The same event will arrive twice: a retry after your 200 was lost in transit, two endpoints on one merchant, or a reconciliation re-drive.
<?php
// The WooCommerce plugin does exactly this, and it is the whole pattern:
if ( ! $order->is_paid() ) {
$order->payment_complete( $transaction_hash );
}
Practical rules:
- Deduplicate on
id(theevt_…), not on the body. Store seen ids for at least 24 hours. - The id is derived from the fact, not from the attempt. An event id is a hash of its type, its data and the merchant it belongs to, so the same fact re-driven by hand or re-queued after a restart carries the same id. Deduplicating on it therefore also protects you from an operator replay, not just from a network retry.
- Guard the side effect too. Only mark unpaid orders paid; only email once; only decrement stock once.
- Answer 200 quickly and do slow work asynchronously. A slow handler burns the delivery timeout and turns into a retry.
- Answer 200 for events you do not care about. A 4xx is read as "never send this again" and counts toward disabling your endpoint.
- Never mutate on an unverified request. Verify, then parse, then act — in that order.
One event belongs to one merchant #
A webhook endpoint is bound to a merchant, and an event is delivered only to that merchant’s endpoints. This is a tenant-isolation property, not an implementation detail: an unscoped fan-out would sign another merchant’s payment.confirmed with your secret, your receiver would verify it correctly and mark your order paid on a payment you never received — while disclosing their amounts and transaction hash to you. There is no legitimate cross-tenant fan-out, so there is no way to ask for one.
Your endpoint URL is also checked against private, loopback and link-local address ranges by resolved address — at registration and again before every attempt. A URL that resolves inside a network is refused rather than turned into a probe with our delivery log as its output.
Rotating a secret #
A rotation signs with the new secret and keeps verifying with the previous one, so both are valid during the overlap. That is why Animica-Signature may carry several comma-separated v1= entries: accept the event if any entry matches any secret you hold, and ignore entries with a scheme you do not recognise so a future v2= does not break you.