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.
Deliveries leave through Tor exit nodes. If your firewall or CDN blocks Tor, events will not reach you.
The request
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.
| Header | Value |
|---|---|
Content-Type | application/json |
X-Event | The event name, the same as event in the body. |
X-Signature | t=<unix seconds>,v1=<hex>. See Verify the signature. |
User-Agent | apa-hooker/3 |
Events
| Event | Sent when |
|---|---|
payment_change | An incoming payment was observed. |
completed | The transaction reached its completion threshold. |
withdrawal.completed | A 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
tandv1. - Compute HMAC-SHA256 over
{t}.{raw body}, keyed with theprivate_keyof the transaction or withdrawal the event is about. - Compare the hex digest with
v1in constant time. - Reject the event if
tis more than 300 seconds from your clock. That is what stops a captured request being replayed.
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"]);
}
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 answer | What happens |
|---|---|
| 2xx | Delivered. Nothing more is sent. |
408425429500502503504507509 | Retried on the schedule below. A Retry-After in seconds is honoured, up to one hour. |
| Timeout or no connection | Retried on the schedule below. |
3xx | Followed for up to three hops, but the POST becomes a GET without a body. Register the final URL. |
| Anything else | Not 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.
- 1now
- 210s
- 320s
- 430s
- 51m 30s
- 62m 30s
- 73m 30s
- 813m 30s
- 91h 13m
- 102h 13m
- 113h 13m
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
httpsURL. The body contains the transaction'sprivate_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_idandevent. - Answer
503when 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.