Skip to main content

Callbacks

When an order changes state we POST a signed event to its callback URL: the notifyUrl given on the order, or the cash desk's URL otherwise. The address is captured when the order is created; changing the cash desk's URL later applies to new orders only.

What arrives

POST https://example.com/payments/hook
Content-Type: application/json; charset=utf-8
User-Agent: OneRamp/1.0 PesoCallback
X-Gate-Ts: 1757001612
X-Gate-Event-Id: evt_01M1P95TKQ8W2N4R6Y7Z0ABCD
X-Gate-Sign: 3c1f… (64 hex characters)
{
"event": "order.completed",
"id": "ord_01M1P932FS0FQPNM3T63KM2XAT",
"orderRef": "A-17",
"status": "Completed",
"amount": { "value": "5000.00", "currency": "RUB" },
"settled": { "value": "54.71000000", "currency": "USD", "rate": "91.40000000" },
"method": "Classic",
"rail": "Sbp",
"occurredAt": "2026-09-04T15:07:41.512Z"
}
EventstatusWhen
order.completedCompletedThe payment is confirmed and credited.
order.cancelledCanceledThe order closed without payment: it expired, you cancelled it, the provider rejected it, or an appeal was rejected.
order.appealedDisputeAn appeal was opened on the order, from the gateway or the cabinet.
order.updatedotherSent only when support re-sends an order that is still open.

The body does not carry the fee or the credited amount; GET /gw/v1/orders/{id} does.

Verify the signature first

X-Gate-Sign = hex(hmac_sha256(cash_desk_secret, "{X-Gate-Ts}.{X-Gate-Event-Id}.{raw body}"))

raw body is the request body exactly as received, before any parsing. Verify with a constant-time comparison before changing anything on your side: otherwise anyone who learns your callback URL can close your orders. Reject signatures older than a few minutes by X-Gate-Ts if you want replay protection on top.

Drop duplicates by event id

Delivery may repeat; the event does not. Every attempt to deliver the same event carries the same X-Gate-Event-Id, whether it is the first try or a retry an hour later. Store the ids you have processed and answer 200 to a repeat without doing anything.

Answer 200

We treat exactly 200 as delivered. On any other outcome:

Your responseWhat we do
200Done.
5xx, 429, no answer within 15 seconds, connection errorRetry with the schedule below.
Any other 4xx, 3xx, 2xx other than 200Treated as a permanent rejection. No retry.

Retries, counted from the first attempt:

AttemptDelay after the previous one
210 s
320 s
440 s
580 s
6160 s
7320 s
8640 s

Eight attempts in all, about 21 minutes. The worker that delivers them runs every 10 seconds, so the first attempt itself lands within about ten seconds of the event. After the last failure the callback is marked failed; you can still fetch the order or press Resend callback on the order page in the cabinet, which sends the current state again as a fresh attempt.

Every attempt, with the HTTP code and the first 300 characters of your response, is listed on the order page. That is the first place to look when "the callback did not come".

Writing the handler

Two rules that matter more than the language: read the raw body before the framework parses it, and answer quickly. Do your own processing after the 200 if it takes time; we do not wait longer than 15 seconds.

Node.js (Express)
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();
app.post('/payments/hook', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body.toString('utf8');
const expected = createHmac('sha256', SECRET)
.update(`${req.get('X-Gate-Ts')}.${req.get('X-Gate-Event-Id')}.${raw}`).digest('hex');
const given = (req.get('X-Gate-Sign') || '').toLowerCase();
if (given.length !== expected.length || !timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
return res.sendStatus(401);
}

const eventId = req.get('X-Gate-Event-Id');
if (await db.events.exists(eventId)) return res.sendStatus(200); // already handled

const event = JSON.parse(raw);
await db.orders.setStatus(event.orderRef ?? event.id, event.status);
await db.events.insert(eventId);
res.sendStatus(200);
});
PHP
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_GATE_TS'] ?? '';
$eventId = $_SERVER['HTTP_X_GATE_EVENT_ID'] ?? '';
$expected = hash_hmac('sha256', "$ts.$eventId.$raw", $secret);
if (!hash_equals($expected, strtolower($_SERVER['HTTP_X_GATE_SIGN'] ?? ''))) {
http_response_code(401);
exit;
}
if (eventAlreadyProcessed($eventId)) { http_response_code(200); exit; }

$event = json_decode($raw, true);
updateOrder($event['orderRef'] ?? $event['id'], $event['status']);
rememberEvent($eventId);
http_response_code(200);
Python (Flask)
import hmac, hashlib
from flask import request, abort

@app.post("/payments/hook")
def hook():
raw = request.get_data() # bytes, before parsing
ts = request.headers.get("X-Gate-Ts", "")
event_id = request.headers.get("X-Gate-Event-Id", "")
expected = hmac.new(SECRET.encode(), f"{ts}.{event_id}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Gate-Sign", "").lower()):
abort(401)
if events.seen(event_id):
return "", 200
event = request.get_json(force=True)
orders.set_status(event.get("orderRef") or event["id"], event["status"])
events.remember(event_id)
return "", 200
C# (ASP.NET Core)
app.MapPost("/payments/hook", async (HttpRequest req, IEventStore events, IOrders orders) =>
{
using var reader = new StreamReader(req.Body);
var raw = await reader.ReadToEndAsync();
var ts = req.Headers["X-Gate-Ts"].ToString();
var eventId = req.Headers["X-Gate-Event-Id"].ToString();
var expected = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret),
Encoding.UTF8.GetBytes($"{ts}.{eventId}.{raw}"))).ToLowerInvariant();
var given = req.Headers["X-Gate-Sign"].ToString().ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(given)))
return Results.Unauthorized();
if (await events.SeenAsync(eventId)) return Results.Ok();

var e = JsonSerializer.Deserialize<OrderEvent>(raw)!;
await orders.SetStatusAsync(e.OrderRef ?? e.Id, e.Status);
await events.RememberAsync(eventId);
return Results.Ok();
});

Things that silently break callbacks

  • A bot check in front of your server. Cloudflare's Browser Integrity Check and similar filters reject requests without a browser-like User-Agent. Ours is OneRamp/1.0 PesoCallback; allow it.
  • A redirect. 301 and 302 are not 200. Point the URL at the final address, with the trailing slash your framework expects.
  • Parsing before verifying. A framework that re-serialises JSON changes the bytes and breaks the signature. Verify against the raw body.
  • A private or non-HTTPS address. Private ranges are refused at creation; http:// is accepted but exposes the event and its signature to the network between us.
  • Callbacks switched off on the cash desk. The cash desk card shows "Callbacks: disabled"; attempts are recorded as suppressed and never sent.