Callbacks¶
When an order is paid, we send a POST to the callbackUrl you gave when
creating the payment. This is how you learn about
the payment: fulfil the order when it arrives.
payment.paid¶
{
"event": "payment.paid",
"orderId": "4f0c2a8e-…",
"reference": "ORDER-8841",
"amount": "215.50",
"amountMinor": 21550,
"currency": "EUR",
"status": "paid",
"paidAt": 1780000000,
"accountLabel": "Stripe 1",
"source": "api"
}
| Header | Value |
|---|---|
X-Payments-Event |
payment.paid |
X-Payments-Signature |
t=<unix time>,v1=<hex> |
source is api for a payment you created. It is payment_link for a payment
through a payment link your account manager made for you. Such a callback has
reference set to the payment's id (pi_…), and two more fields:
paymentLinkId and customFields, what the customer typed into the link's
fields:
"customFields": { "Telegram username": "@bea" }
Check the signature¶
v1 is an HMAC-SHA256 of {t}.{raw body} with your callback secret (on the
Account page of the merchant portal). Check it on the raw
body, before parsing the JSON, and refuse a timestamp older than 5 minutes.
import crypto from "node:crypto";
export async function POST(req) {
const raw = await req.text(); // the raw body, not req.json()
const header = req.headers.get("x-payments-signature") ?? "";
const [, t, v1] = /^t=(\d+),v1=([a-f0-9]+)$/.exec(header) ?? [];
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) {
return new Response("bad signature", { status: 400 });
}
const expected = crypto
.createHmac("sha256", process.env.PAYMENTS_CALLBACK_SECRET)
.update(`${t}.${raw}`)
.digest("hex");
const same = v1.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
if (!same) return new Response("bad signature", { status: 400 });
const event = JSON.parse(raw);
// Find your order by event.reference, compare event.amount and
// event.currency, fulfil it once, then answer 2xx.
return Response.json({ ok: true });
}
<?php
$raw = file_get_contents('php://input'); // the raw body
preg_match('/^t=(\d+),v1=([a-f0-9]+)$/', $_SERVER['HTTP_X_PAYMENTS_SIGNATURE'] ?? '', $m);
if (!$m || abs(time() - (int) $m[1]) > 300) { http_response_code(400); exit('bad signature'); }
$expected = hash_hmac('sha256', $m[1] . '.' . $raw, getenv('PAYMENTS_CALLBACK_SECRET'));
if (!hash_equals($expected, $m[2])) { http_response_code(400); exit('bad signature'); }
$event = json_decode($raw, true);
// Find your order by $event['reference'], compare amount and currency,
// fulfil it once, then answer 2xx.
http_response_code(200);
echo '{"ok":true}';
import hashlib, hmac, json, os, re, time
def handle_callback(raw: bytes, signature: str) -> int:
m = re.fullmatch(r"t=(\d+),v1=([a-f0-9]+)", signature or "")
if not m or abs(time.time() - int(m[1])) > 300:
return 400
secret = os.environ["PAYMENTS_CALLBACK_SECRET"].encode()
expected = hmac.new(secret, m[1].encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, m[2]):
return 400
event = json.loads(raw)
# Find your order by event["reference"], compare amount and currency,
# fulfil it once, then answer 2xx.
return 200
Your handler must¶
- Answer 2xx within 15 seconds. Anything else counts as not delivered, and we try again.
- Fulfil once. The same
payment.paidcan arrive more than once; a repeat for an order you already fulfilled must do nothing. - Check the amount against your order before fulfilling.
Retries¶
A callback that was not delivered is retried until your server takes it. The pause doubles after each failure, up to an hour:
| Attempt | After the one before |
|---|---|
| 2nd | 1 minute |
| 3rd | 2 minutes |
| 4th | 4 minutes |
| … | doubling |
| from the 8th on | 1 hour |
Retries run every few minutes, so a pause can be a little longer. Your account manager can also resend a callback by hand. If your server was down for long, get the order: it sends the callback as soon as it sees the order paid.
Declined cards¶
A declined card sends no callback: the customer sees the decline on the payment
page and can try another card, and the order stays open. The reason is in
attempts when you get the order.