Skip to content

Callbacks

A callback is a JSON POST that the API sends to your server when a payment is paid or canceled. It tells you the result without polling. Every callback is signed with the project secret: verify the signature before trusting the content.

How it works

  1. APIThe payment is closed — completed or canceled. An event with a new event_id is created
  2. API → Your serverPOST to the callback URL, signed in X-Callback-Signature
  3. Your serverVerifies the signature and time, looks the event_id up among processed ones
  4. Your server ⇢ APIAnswers 2xx at once, before handling the order
  5. Your serverUpdates the order by id and status in the background
  6. API → Your serverNo 2xx — the same event again after 30 and after 60 seconds

Key points:

  • A callback is an event notification. The source of truth is the payment in the API: GET /api/v1/payments/{id}.
  • One event may arrive more than once; events of one payment may arrive out of order.
  • One payment may get several different events: for example canceled and then completed — see Status changes after closing.
  • There are few attempts — 3 within a minute and a half. Answer 2xx quickly and keep a reconciliation in case a callback does not get through.

When it arrives

A callback arrives when a live payment moves to one of two statuses:

Status What happened What to do
completed Payment confirmed Fulfil the order for amount
canceled Not paid: its lifetime ran out, it was canceled, or the payment could not be confirmed after requisites were issued Do not fulfil; offer to pay again — with a new payment

The callback does not carry the cancellation reason: for you every case is the same — the requisites are no longer valid.

A callback does not arrive:

  • for a payment in error. error happens only before requisites are issued, and you learn about it from the create response or from GET /api/v1/payments/{id}. Once requisites are issued, a payment ends only as completed or canceled, and either one brings a callback;
  • for intermediate statuses created, processing and appeal (the payment is being re-checked — the callback comes with the outcome);
  • for test payments (is_test: true) — see Test payments;
  • if no callback URL is set on the payment, the project or the merchant — see Where it goes.

Status changes after closing

completed and canceled are final, but a payment re-check or a support decision can change them. Every change is a new event with a new event_id and brings a new callback:

Was Received When
canceled completed The payer paid after the payment lifetime, or the payment was confirmed later
completed completed with another amount The payer sent a different amount and it was confirmed. initial_amount does not change
completed canceled The payment was not confirmed on re-check
any the same status again The re-check kept the status, or support resent the callback

Your handler must be able to:

  • fulfil an order of a canceled payment if completed arrives later;
  • recalculate the order if a new completed has another amount;
  • revoke or hold the order if canceled arrives after completed.

Events may arrive out of order

If a callback changes what you have already stored for the payment, re-read it with GET /api/v1/payments/{id} and apply the status and amount from the API response. This way a late event cannot roll the order back.

Where it goes

The first URL that is set wins:

  1. callback_url from the create request;
  2. the project callback URL (cabinet, Projects and API);
  3. the merchant's common callback URL (cabinet, merchant settings).

If no URL is set at any level, no callback is sent: read the result with GET /api/v1/payments/{id}.

URL requirements:

  • absolute https://, no credentials, up to 2048 characters, not localhost;
  • the host resolves to public IPs only. If any of its addresses is internal (private networks, loopback, link-local, CGNAT 100.64.0.0/10), no connection is made;
  • redirects are not followed: a 3xx response is a failure. Use the final URL.

Body

The body is an event about the payment's transition to a status: the main payment fields, the rate and fee, event_id and project_id. It has no requisites or USDT amounts — the full payment is returned by GET /api/v1/payments/{id} (fields).

{
  "id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42",
  "event_id": "5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21",
  "amount": 3400,
  "initial_amount": 3400,
  "status": "completed",
  "currency": "RUB",
  "payment_method": "sbp",
  "bank_code": "sber",
  "merchant_payment_id": "order-42",
  "rate": 11.00,
  "course": 100.00,
  "is_intrabank": false,
  "is_test": false,
  "project_id": "5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b"
}
Field Meaning
id Payment id from the create response
event_id Event id. The same in every retry of one event and equal to the X-Callback-Event-Id header
status Event status: completed or canceled. On a manual resend, the payment's current closed status, error included
amount Credited amount in major currency units. After a payment re-check it may differ from initial_amount — fulfil the order by amount
initial_amount Amount from the create request, never changes
merchant_payment_id Your order id
rate, course Your fee, %, and rate — payment currency per 1 USDT. Numbers with two decimals, the same as in merchant_information; null if no requisites were issued
is_test true for a project test payment
project_id Project the payment belongs to
other fields As in the create response: currency, payment_method, bank_code, is_intrabank
  • There is no error code in a callback, even on a manual resend of a payment in error: read the reason with GET.
  • Parse amount, initial_amount, rate and course as decimals, not floats.
  • New fields may be added: ignore unknown fields.
  • The body is built once, when the event is queued, and is byte-for-byte the same in every delivery retry. When in doubt about freshness, re-read the payment with GET.

Headers

Header Value
Content-Type application/json
X-Callback-Event-Id The event's event_id
X-Callback-Timestamp Unix time of this attempt, seconds
X-Callback-Signature v1=<hex>; for 24 hours after a secret rotation, v1=<hex>,v1=<hex>

Signature verification

signed_payload = X-Callback-Timestamp + "." + raw_request_body
signature      = hex( HMAC-SHA256( key = the whole project secret, message = signed_payload ) )
  • The secret is a whsec_… string. The HMAC key is the whole string as UTF-8, whsec_ included. Nothing needs decoding.
  • hex is 64 lower-case characters.
  • Compute over the raw body bytes before parsing JSON. Parsing and re-serializing may change the bytes.
  • The request is authentic if at least one v1= value matches your signature. Compare in constant time. Skip values with another scheme (not v1).

Replay protection

  1. Reject the request if X-Callback-Timestamp differs from your clock by more than 5 minutes. Keep your clock synced with NTP. The timestamp is part of the signature and cannot be forged.
  2. Keep processed event_id values for at least a day. Do not process a repeated event_id again — just answer 2xx.

event_id identifies an event, not a payment and not an attempt:

What happened event_id Body Timestamp and signature
Delivery retry after an error or a non-2xx answer same byte-for-byte same new
New payment status new new new
Manual resend by support new, even if the status did not change new new

Decide the payment outcome by id and status; use event_id only to avoid processing one delivery twice.

Verification functions

The functions check the signature and freshness and are tested on the vector below.

For debugging in a terminal. In your application use your language's function: shell cannot compare strings in constant time.

# Payment API callback signature check (HMAC-SHA256) for debugging in a terminal.
# In your application verify with code that compares in constant time.
# POSIX sh + openssl. Load with: . ./verify_callback.sh

# api_callback_signature SECRET TIMESTAMP BODY_FILE — the expected signature (hex).
api_callback_signature() {
  { printf '%s.' "$2"; cat "$3"; } | openssl dgst -sha256 -hmac "$1" -r | cut -d' ' -f1
}

# api_verify_callback SECRET TIMESTAMP SIGNATURE_HEADER BODY_FILE [NOW]
# Exit code 0: the signature matches and the timestamp is fresh (±300 seconds).
api_verify_callback() {
  api_now=${5:-$(date +%s)}
  api_skew=$((api_now - $2)); [ "$api_skew" -lt 0 ] && api_skew=$((-api_skew))
  [ "$api_skew" -le 300 ] || return 1
  api_expected=$(api_callback_signature "$1" "$2" "$4")
  for api_part in $(printf '%s' "$3" | tr ',' ' '); do
    [ "$api_part" = "v1=$api_expected" ] && return 0
  done
  return 1
}
<?php
// Payment API callback signature check: HMAC-SHA256. PHP 8.1+, no extensions.

declare(strict_types=1);

const CALLBACK_MAX_SKEW = 300; // reject callbacks more than 5 minutes old or ahead

/**
 * true if the callback is authentic and fresh.
 * $secret    — the whole project secret, including the whsec_ prefix;
 * $timestamp — the X-Callback-Timestamp header, $signature — X-Callback-Signature;
 * $body      — the raw body: file_get_contents('php://input'), before json_decode.
 */
function api_verify_callback(string $secret, string $timestamp, string $signature, string $body, ?int $now = null): bool
{
    if (!ctype_digit($timestamp)) {
        return false;
    }
    if (abs(($now ?? time()) - (int) $timestamp) > CALLBACK_MAX_SKEW) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
    foreach (explode(',', $signature) as $part) {
        [$scheme, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($scheme === 'v1' && hash_equals($expected, $value)) {
            return true;
        }
    }
    return false;
}
"""Payment API callback signature check: HMAC-SHA256. Standard library only."""
import hashlib
import hmac
import time

MAX_SKEW_SECONDS = 300  # reject callbacks more than 5 minutes old or ahead


def verify_callback(secret: str, headers, body: bytes, now: float | None = None) -> bool:
    """True if the callback is authentic and fresh.

    secret  — the whole project secret, including the whsec_ prefix;
    headers — request headers (any object with .get);
    body    — raw body bytes, before JSON parsing.
    """
    timestamp = headers.get("X-Callback-Timestamp", "")
    signature = headers.get("X-Callback-Signature", "")
    if not timestamp.isdigit():
        return False
    now = time.time() if now is None else now
    if abs(now - int(timestamp)) > MAX_SKEW_SECONDS:
        return False
    signed = timestamp.encode() + b"." + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    for part in signature.split(","):
        scheme, _, value = part.strip().partition("=")
        if scheme == "v1" and hmac.compare_digest(value, expected):
            return True
    return False
// Payment API callback signature check: HMAC-SHA256. Node.js 20+, no dependencies.
import { createHmac, timingSafeEqual } from 'node:crypto';

const MAX_SKEW_SECONDS = 300; // reject callbacks more than 5 minutes old or ahead

/**
 * true if the callback is authentic and fresh.
 * secret  — the whole project secret, including the whsec_ prefix;
 * headers — request headers with lower-case names (as in node:http);
 * body    — raw body bytes (Buffer), before JSON parsing.
 */
export function verifyCallback(secret, headers, body, now = Date.now() / 1000) {
  const timestamp = String(headers['x-callback-timestamp'] ?? '');
  const signature = String(headers['x-callback-signature'] ?? '');
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(now - Number(timestamp)) > MAX_SKEW_SECONDS) return false;

  const expected = Buffer.from(
    createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex'),
  );
  return signature.split(',').some((part) => {
    const [scheme, value = ''] = part.trim().split('=', 2);
    const candidate = Buffer.from(value);
    return scheme === 'v1' && candidate.length === expected.length && timingSafeEqual(candidate, expected);
  });
}
package paymentapi

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "net/http"
    "strconv"
    "strings"
    "time"
)

// maxSkew: callbacks more than 5 minutes old or ahead are rejected.
const maxSkew = 5 * time.Minute

// VerifyCallback checks the callback signature and freshness. secret is the whole project
// secret including whsec_; body is the raw request body, before JSON parsing.
func VerifyCallback(secret string, header http.Header, body []byte, now time.Time) bool {
    timestamp := header.Get("X-Callback-Timestamp")
    sent, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false
    }
    if skew := now.Sub(time.Unix(sent, 0)); skew > maxSkew || skew < -maxSkew {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp + "."))
    mac.Write(body)
    expected := []byte(hex.EncodeToString(mac.Sum(nil)))
    for _, part := range strings.Split(header.Get("X-Callback-Signature"), ",") {
        scheme, value, ok := strings.Cut(strings.TrimSpace(part), "=")
        if ok && scheme == "v1" && hmac.Equal([]byte(value), expected) {
            return true
        }
    }
    return false
}

Test vector

body is exactly one line with no line breaks and no spaces between fields (the body from the example above as the API sends it), 362 bytes:

secret    = whsec_MfKQ9r2vXz
timestamp = 1700000000
body      = {"id":"3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42","event_id":"5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21","amount":3400,"initial_amount":3400,"status":"completed","currency":"RUB","payment_method":"sbp","bank_code":"sber","merchant_payment_id":"order-42","rate":11.00,"course":100.00,"is_intrabank":false,"is_test":false,"project_id":"5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b"}
signature = e3e688bac4039def6f680c16f14701866d8e5e2de66d9b37f9ecc8bccb90220f

With the previous secret whsec_previous the same body gives 54b9a97acdb1adf27f81bc49bdc9d963b4fe4f3bf5114ea3303581084e99f3b4, and during the overlap the header looks like v1=e3e688ba…90220f,v1=54b9a97a…99f3b4.

How to handle it

  1. Read the raw body and headers.
  2. Verify the signature and X-Callback-Timestamp. If they fail, answer 401 and change nothing.
  3. Insert event_id into a table with a unique index. Already there — answer 2xx and stop.
  4. Queue the event in your own queue and answer 2xx at once.
  5. In the background find the order by merchant_payment_id or the stored id and apply status and amount following the status change rules.

2xx means "event accepted", not "order fulfilled". If your processing fails after the answer, re-read the payment with GET: the callback will not come again by itself.

Minimal receiver

Verifies the signature, drops duplicates and answers 2xx at once. In production store event_id in a database with a unique index and queue the processing.

<?php
// Minimal callback receiver: put this file behind your web server (or php -S 0.0.0.0:8080).
// The secret comes from the CALLBACK_SECRET environment variable.

declare(strict_types=1);

require __DIR__ . '/callback.php';

$body = file_get_contents('php://input');
$valid = api_verify_callback(
    getenv('CALLBACK_SECRET') ?: '',
    $_SERVER['HTTP_X_CALLBACK_TIMESTAMP'] ?? '',
    $_SERVER['HTTP_X_CALLBACK_SIGNATURE'] ?? '',
    $body,
);
if (!$valid) {
    http_response_code(401);
    exit;
}
$event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
// Store event_id with a unique index: never process the same event twice.
// Enqueue the work here: mark the order as paid, etc.
error_log("payment {$event['merchant_payment_id']} -> {$event['status']}");
http_response_code(204); // answer 2xx at once, process asynchronously
"""Minimal callback receiver on the standard library.

    export CALLBACK_SECRET=whsec_...
    python callback_server.py   # listens on :8080, any path
"""
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

from callback import verify_callback

SECRET = os.environ.get("CALLBACK_SECRET", "")
seen_events: set[str] = set()  # in production: a database table with a unique event_id


class CallbackHandler(BaseHTTPRequestHandler):
    def do_POST(self) -> None:
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        if not verify_callback(SECRET, self.headers, body):
            self.send_response(401)
            self.end_headers()
            return
        event = json.loads(body)
        if event["event_id"] not in seen_events:
            seen_events.add(event["event_id"])
            # Enqueue the work here: mark the order as paid, etc.
            print("payment", event["merchant_payment_id"], "->", event["status"])
        self.send_response(204)  # answer 2xx at once, process asynchronously
        self.end_headers()


if __name__ == "__main__":
    HTTPServer(("", 8080), CallbackHandler).serve_forever()
// Minimal callback receiver on node:http.
//
//   export CALLBACK_SECRET=whsec_...
//   node callback-server.mjs   # listens on :8080, any path
import { createServer } from 'node:http';
import { verifyCallback } from './callback.mjs';

const secret = process.env.CALLBACK_SECRET ?? '';
const seenEvents = new Set(); // in production: a database table with a unique event_id

createServer((req, res) => {
  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const body = Buffer.concat(chunks);
    if (req.method !== 'POST' || !verifyCallback(secret, req.headers, body)) {
      res.writeHead(401).end();
      return;
    }
    const event = JSON.parse(body);
    if (!seenEvents.has(event.event_id)) {
      seenEvents.add(event.event_id);
      // Enqueue the work here: mark the order as paid, etc.
      console.log('payment', event.merchant_payment_id, '->', event.status);
    }
    res.writeHead(204).end(); // answer 2xx at once, process asynchronously
  });
}).listen(8080);
// Minimal callback receiver on net/http.
//
//  export CALLBACK_SECRET=whsec_...
//  go run ./cmd/callback-server   # listens on :8080, any path
package main

import (
    "encoding/json"
    "io"
    "log"
    "net/http"
    "os"
    "sync"
    "time"

    "example.com/paymentapi"
)

func main() {
    secret := os.Getenv("CALLBACK_SECRET")
    var mu sync.Mutex
    seen := map[string]bool{} // in production: a database table with a unique event_id

    http.HandleFunc("POST /", func(w http.ResponseWriter, r *http.Request) {
        body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
        if err != nil || !paymentapi.VerifyCallback(secret, r.Header, body, time.Now()) {
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        var event struct {
            EventID           string `json:"event_id"`
            MerchantPaymentID string `json:"merchant_payment_id"`
            Status            string `json:"status"`
        }
        if err = json.Unmarshal(body, &event); err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        mu.Lock()
        first := !seen[event.EventID]
        seen[event.EventID] = true
        mu.Unlock()
        if first {
            // Enqueue the work here: mark the order as paid, etc.
            log.Printf("payment %s -> %s", event.MerchantPaymentID, event.Status)
        }
        w.WriteHeader(http.StatusNoContent) // answer 2xx at once, process asynchronously
    })
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Delivery and retries

  • Success is any 2xx. The response body is not read beyond 4 KB.
  • Failure is a network error, a timeout or any other code: 3xx, 4xx, 5xx.
  • Timeout — 10 seconds per request. Answer 2xx at once and process asynchronously.
  • Retries. An event gets 3 attempts: at once, after 30 seconds and after another 60 seconds — about a minute and a half. All attempts carry the same event_id and body. After the third failure delivery of this event stops.
  • Manual resend. Support can send a callback for a closed payment again, including a payment in error. It is a new event with a new event_id and a single attempt, without automatic retries.
  • No ordering guarantee. A retry of an old event may arrive after a new one. Make the handler idempotent.
Attempt When If no 2xx
1 right after the event attempt 2 in 30 seconds
2 after 30 seconds attempt 3 in 60 seconds
3 after 90 seconds delivery stops

When in doubt, re-read the payment

If a callback contradicts what you know about the order, read the current state with GET /api/v1/payments/{id}. The API answer beats delivery order.

If a callback did not arrive

There are few attempts, so do not rely on callbacks alone:

  • Reconciliation. Every few minutes select your orders that have waited for payment longer than the project payment lifetime (15 minutes by default) and read them with GET /api/v1/payments/{id}. Handle a closed payment the same way as a received callback. Do not poll more often than once per 5–10 seconds per payment: the project shares one rate limit.
  • Check the URL. It is set on at least one level, reachable from the internet, answers 2xx without a redirect, and its certificate is valid.
  • Resend. If your server was down, send support the payment id values or their merchant_payment_id — the callbacks will be sent again. Each comes with a new event_id.

Common problems

Symptom Cause
Signature does not match Signed over parsed and re-serialized JSON instead of the raw body; key taken without whsec_; secret from another project or environment
Signature is right, but the request is rejected by time Server clock is off by more than 5 minutes
No callbacks at all URL not set; URL answers 301 or 302; host resolves to an internal IP; payments are test ones
Order processed twice Retries are not dropped by event_id, or the outcome is decided by event_id instead of id and status
Order "rolled back" A late event was applied over a newer one. Re-read the payment with GET when the status changes

Test payments

Payments with is_test: true — project test payments and test payments from the cabinet — do not get callbacks automatically. Read their outcome with GET /api/v1/payments/{id}.

To test your handler:

  • run your verification function on the test vector;
  • ask support to resend the callback of a closed test payment — a real signed request with is_test: true will reach your URL.

More in Sandbox.

Secret and rotation

  • The secret is issued in the cabinet: Projects and API → External API access → Callback. It is shown once — keep it in a secret store.
  • Each project has its own secret; sandbox and production secrets differ.
  • If no secret was issued, the platform creates one on first delivery but shows it to nobody. To verify signatures, issue a new one.
  • Lossless rotation. After a new secret is issued, the previous one keeps signing for 24 hours as a second v1 value. Steps: issue the new secret → add it next to the old one (or replace at once — any matching value is fine) → remove the old one within 24 hours. Rotating again within those 24 hours drops the oldest secret immediately: at most two are valid at a time.
  • If the secret leaks, issue a new one and remove the old one from your check right away.

Checklist

  • The callback URL is set, reachable from the internet, https://, no redirect.
  • The signature is checked over the raw body with a constant-time comparison.
  • Requests older than 5 minutes are rejected; the clock is synced.
  • event_id is stored with a unique index for at least a day.
  • 2xx goes out at once; processing runs in the background.
  • The order is fulfilled by amount, not initial_amount.
  • canceled → completed, completed → canceled and a new amount are handled.
  • Orders without a callback are reconciled with GET /api/v1/payments/{id}.