Guides

Webhooks

We POST a signed JSON event to your server when a payment arrives, when a transaction completes, and when a payout is broadcast. Verify the signature, answer quickly, and expect the occasional repeat.

How delivery works

Set a webhook URL when you create a transaction or request a withdrawal. Every event about that object goes to that URL. There is no account-wide endpoint to configure.

Step 1Something happensA payment is seen, a transaction completes, or a payout is broadcast.
Step 2We send a POSTSigned JSON, sent through Tor by our delivery service.
Step 3You answer 2xxAnything else, or no answer at all, and we try again later.
Allow Tor traffic

Deliveries leave through Tor exit nodes. If your firewall or CDN blocks Tor, events will not reach you.

The request

Example delivery
POST /hooks/anonpay HTTP/1.1
Host: shop.example
Content-Type: application/json
User-Agent: apa-hooker/3
X-Event: completed
X-Signature: t=1753920000,v1=5f0d0a1c8b7e3d2f4a6b9c1e0d3f5a7b9c2e4f6a8b0d1c3e5f7a9b2c4d6e8f0a

{"event":"completed","payment":{"address":"bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq","confirmations":6,"confirmations_needed":6,"currency":"btc","paid":"0.00021","rate":"0.0000105","topay":"0.00021"},"private_key":"a3f9d0e1c2b4a6f8e0d2c4b6a8f0e2d4","timestamp":1753920000,"transaction_id":"9c1f0b7a4e2d8f6a3b5c7d9e1f0a2b4c"}

The body is compact JSON with sorted keys. Verify the signature against these exact bytes: parsing and re-serialising the JSON produces different bytes, and a signature that no longer matches.

HeaderValue
Content-Typeapplication/json
X-EventThe event name, the same as event in the body.
X-Signaturet=<unix seconds>,v1=<hex>. See Verify the signature.
User-Agentapa-hooker/3

Events

EventSent when
payment_changeAn incoming payment was observed.
completedThe transaction reached its completion threshold.
withdrawal.completedA payout was broadcast.

Every body carries event, timestamp and private_key. Webhook events lists every field of each.

Verify the signature

X-Signature holds a timestamp and an HMAC. To check it:

  • Split the header on commas and read t and v1.
  • Compute HMAC-SHA256 over {t}.{raw body}, keyed with the private_key of the transaction or withdrawal the event is about.
  • Compare the hex digest with v1 in constant time.
  • Reject the event if t is more than 300 seconds from your clock. That is what stops a captured request being replayed.
Verify
import hashlib
import hmac
import time


def verify(secret: str, header: str, body: bytes) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        timestamp, signature = int(parts["t"]), parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > 300:
        return False
    signed = f"{timestamp}.".encode() + body
    digest = hmac.new(secret.encode(), signed, hashlib.sha256)
    return hmac.compare_digest(digest.hexdigest(), signature)
import crypto from "node:crypto";

export function verify(secret, header, body) {
  const parts = Object.fromEntries(
    header.split(",").map((part) => part.split("=", 2)),
  );
  const timestamp = Number(parts.t);
  if (!parts.v1 || !Number.isInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(body)
    .digest("hex");
  return expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
function verify(string $secret, string $header, string $body): bool
{
    $parts = [];
    foreach (explode(",", $header) as $part) {
        [$key, $value] = array_pad(explode("=", $part, 2), 2, "");
        $parts[$key] = $value;
    }
    if (!isset($parts["t"], $parts["v1"]) || !ctype_digit($parts["t"])) {
        return false;
    }
    if (abs(time() - (int) $parts["t"]) > 300) {
        return false;
    }
    $expected = hash_hmac("sha256", $parts["t"] . "." . $body, $secret);
    return hash_equals($expected, $parts["v1"]);
}
The private_key in the body

It predates signatures and is still sent so older integrations keep working. Matching it proves the sender knows the secret, not that the request is fresh. Check the signature instead.

Answer and retries

Answer within 30 seconds, and do slow work after you respond. What happens next depends on your status code.

You answerWhat happens
2xxDelivered. Nothing more is sent.
408425429500502503504507509
Retried on the schedule below. A Retry-After in seconds is honoured, up to one hour.
Timeout or no connectionRetried on the schedule below.
3xxFollowed for up to three hops, but the POST becomes a GET without a body. Register the final URL.
Anything elseNot retried. Use it for requests you reject on purpose, such as a bad signature.

There are eleven attempts over about three hours. Each delay varies by up to 20%, so a backlog does not reach you all at once.

  1. 1now
  2. 210s
  3. 320s
  4. 430s
  5. 51m 30s
  6. 62m 30s
  7. 73m 30s
  8. 813m 30s
  9. 91h 13m
  10. 102h 13m
  11. 113h 13m
After the eleventh attempt the event is dropped

The transaction itself is unaffected. You can always fetch it from the API, so a reconciliation job can catch anything you missed.

Checklist

  • Use an https URL. The body contains the transaction's private_key.
  • Verify the signature before you act on anything in the body.
  • Make the handler idempotent. The same event can arrive twice, so key your work on transaction_id and event.
  • Answer 503 when you cannot process an event right now, so it comes back.
  • Register the final URL. A redirect turns the POST into a GET without a body.
  • The URL must resolve to a public address. Onion and private addresses are refused when you create the transaction.