Skip to content

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

  1. Answer 2xx within 15 seconds. Anything else counts as not delivered, and we try again.
  2. Fulfil once. The same payment.paid can arrive more than once; a repeat for an order you already fulfilled must do nothing.
  3. 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.