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"
}
| Event | status | When |
|---|---|---|
order.completed | Completed | The payment is confirmed and credited. |
order.cancelled | Canceled | The order closed without payment: it expired, you cancelled it, the provider rejected it, or an appeal was rejected. |
order.appealed | Dispute | An appeal was opened on the order, from the gateway or the cabinet. |
order.updated | other | Sent 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 response | What we do |
|---|---|
200 | Done. |
5xx, 429, no answer within 15 seconds, connection error | Retry with the schedule below. |
Any other 4xx, 3xx, 2xx other than 200 | Treated as a permanent rejection. No retry. |
Retries, counted from the first attempt:
| Attempt | Delay after the previous one |
|---|---|
| 2 | 10 s |
| 3 | 20 s |
| 4 | 40 s |
| 5 | 80 s |
| 6 | 160 s |
| 7 | 320 s |
| 8 | 640 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.
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);
});
$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);
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
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.
301and302are not200. 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.