Animica Pay docs 2.00% per successful payment

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.

TypeMeaning
payment.createdAn intent was created and is awaiting payment.
payment.detectedA matching transfer was found in a block. Not settlement.
payment.confirmingDepth is accumulating. Fires on entry to CONFIRMING, not per block.
payment.confirmedRequired depth reached and the receipt succeeded. This is the one that means paid.
payment.failedThe transaction executed unsuccessfully, or the intent was failed administratively.
payment.expiredThe checkout window closed unpaid. A later payment can still arrive.
payment.underpaidLess arrived than requested. Never auto-settles; your decision.
payment.overpaidMore arrived than requested. Never auto-settles; your decision.
payment.reorgedA 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.createdA refund was requested and a transfer is being prepared.
refund.completedThe refund transfer confirmed with a successful receipt.
invoice.paidAn 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.

json
{
  "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 #

HeaderExampleSigned
Animica-Signaturev1=9f8c… (comma-separated during rotation)n/a
Animica-Event-IDevt_4f1c…yes
Animica-Timestamp1786000000yes
Animica-Delivery-Attempt3no — a debugging aid. Never trust it.

The signature scheme #

HMAC-SHA256 over this exact string:

text
v1:<unix_seconds>:<event_id>:<raw_body>

Three properties, all deliberate:

  1. 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.
  2. The event id is inside the signed string, so a captured payment.confirmed cannot be re-labelled as another event.
  3. Compare in constant time. An HMAC check that returns on the first differing byte is a byte-at-a-time forgery oracle.

Node #

js
// 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
<?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 attemptsNominal delayJitter
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:

ResponseTreated as
2xxdelivered
408, 429, 5xxretryable
other 4xxpermanent — resending identical bytes cannot change your mind
3xxpermanent — redirects are not followed, because following one would replay a signed payment event at whatever host the redirect names
connection error, DNS, TLS, timeoutretryable

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
<?php
// The WooCommerce plugin does exactly this, and it is the whole pattern:
if ( ! $order->is_paid() ) {
    $order->payment_complete( $transaction_hash );
}

Practical rules:

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.