Guide
Markdown versionWebhooks
A notification to your server when an order reaches a final status.
Pass webhookUrl in the POST /purchase body. When the order becomes completed, failed or cancelled, we send a POST request to that URL:
{
"event": "order.status",
"orderId": 1842,
"status": "completed",
"failureReason": null,
"productId": 12,
"price": 1490,
"currency": "RUB",
"idempotencyKey": "order-7731",
"createdAt": "2026-11-13T12:36:53.000Z"
}The webhook does not contain the product key — get it via GET /orders/{orderId}.
Headers
| Header | Value |
|---|---|
X-Merchant-Timestamp | Send time, Unix seconds. |
X-Merchant-Signature | sha256= + hex HMAC-SHA256 of the string <timestamp>.<request body> with your webhook secret. The secret is issued together with your access key. |
User-Agent | merchant-webhooks/1 |
Verifying the signature
Always check the signature and the timestamp — otherwise anyone can send you a “paid” order.
import crypto from "node:crypto";
// rawBody is the request body as a string, BEFORE JSON.parse: the signature covers the exact bytes.
export function verifyMerchantWebhook(rawBody, headers, secret) {
const timestamp = headers["x-merchant-timestamp"];
const signature = headers["x-merchant-signature"] ?? "";
// Drop old deliveries — protects against replaying an intercepted request.
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express:
// app.post("/hooks/merchant", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyMerchantWebhook(req.body.toString("utf8"), req.headers, process.env.MERCHANT_WEBHOOK_SECRET))
// return res.sendStatus(401);
// const event = JSON.parse(req.body);
// res.sendStatus(200); // answer fast, queue the processing
// });import hashlib
import hmac
import time
def verify_merchant_webhook(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Merchant-Timestamp", "")
signature = headers.get("X-Merchant-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
message = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)Delivery and retries
- A
2xxresponse within 5 seconds means delivered. Answer right away and queue the processing. - Network error, timeout,
5xx,408,429— retried after 30 s, 2 min, 10 min, 30 min, 2 h and 6 h (up to 7 attempts in total). - Redirects (
3xx) are not followed; other4xxare a final refusal with no retries. - The result is in the order's
webhookStatusfield:pending,deliveredorfailed. - One event can arrive more than once. Process idempotently by the
orderId+statuspair.
URL requirements
https:// only, up to 2048 characters, no username or password in the URL. No localhost, internal or reserved IP addresses — neither directly nor through a DNS name pointing at them. An invalid URL gives 400 Invalid webhookUrl (reason).
In the sandbox a webhook is sent only for a successful test purchase, contains "sandbox": true and is retried up to 3 times.