Webhooks
How nspay notifies the merchant when an operation reaches a terminal state.
When an operation transitions into a terminal status, nspay POSTs the
operation snapshot to the callback URL configured for the shop
that owns the operation. This removes the need to poll
/v1/get_operation_state.
When a callback fires
By default a callback is sent for terminal statuses completed, failed,
rejected, and cancelled. A shop may configure a strict callback-status
filter; when it is non-empty, only listed terminal statuses are delivered. If
no provider route accepts the operation, it is recorded as rejected and the
callback is delivered only when that status passes the shop filter.
A callback is also sent when a payment enters awaiting_confirmation —
this is an intermediate status (not terminal) that fires early so you
can prompt the end user to upload a receipt. The terminal callback still
fires later once the receipt is verified and the payment reaches a terminal
status. Treat the awaiting_confirmation callback as an early, best-effort
notification. It is attempted once and does not use the terminal retry schedule.
A callback is not sent for pending — by design.
Configuration
The callback URL is configured per shop in the admin UI (Shops → edit shop → Callback URL). It must be:
- An absolute
https://URL (orhttp://for testing). - Reachable from the dispatcher within 10 seconds (the per-attempt timeout).
If the callback URL is empty for a shop, no webhook is delivered. The
operation still reaches its terminal status; the merchant is expected
to read state via
/v1/get_operation_state. A skipped
delivery is still recorded in the per-operation callback_attempts
audit log with status skipped.
Request
POST <callback_url>
Content-Type: application/jsonBody — a public snapshot of the operation:
{
"operation_id": "f6c2e7d4-3e5b-4f1f-8a02-9d6c7e8a1b22",
"type": "payment",
"status": "completed",
"method": "WT_RUB_PHONE",
"provider": "provider-sandbox",
"expected_amount": "1500.00",
"actual_amount": "1500.00",
"currency": "RUB",
"actual_currency": "RUB",
"settlement_amount": "1470.00",
"settlement_currency": "RUB",
"commission_amount": "30.00",
"external_confirmation": null,
"payout_destination": {
"phone": {
"phone_number": "+79991234567",
"name": "Ivan Ivanov",
"bank": "sber"
}
}
}provider is an opaque internal routing label (which provider/rail handled the
operation) — it may be absent, and merchants should not depend on its exact
value; treat it as informational only, same as on create/get responses.
payout_destination is a typed sum: exactly one of phone or c2c is
set (the variant lines up with Method), or the field is null if
nspay has not yet allocated a destination. The card variant looks
like {"c2c": {"card_number": "4111…", "name": "…"}}.
actual_currency is the currency actual_amount is denominated in (the
source currency you sent). fx_rate/fx_source (the conversion quote) are
present as soon as a route with auto-conversion is chosen — even on the early
awaiting_confirmation callback, before money has actually moved.
recorded_currency/recorded_amount are the ledger FACT (actual amount ×
rate) and are gated on the operation actually having a non-zero
actual_amount — they are absent on failed/rejected/cancelled callbacks
(no money moved) and present on completed. recorded_amount is additionally
exposed as a minor-units pair (recorded_amount_minor +
recorded_amount_scale) for integrators that account in minor units,
following the same gate.
settlement_amount is the amount that actually moved on your balance, net of
the nspay commission, denominated in settlement_currency (the ledger currency
— equal to currency when no conversion happened). For a payment it is the
amount credited to you after commission (gross − commission_amount); for a
payout it is the total charged to you including commission
(gross + commission_amount). commission_amount is the nspay commission for
the operation, in the same currency. On a partial capture both figures reflect
the captured amount.
Use operation_id to match the callback against the create response
and to call /v1/get_operation_state if a follow-up reconciliation is
needed.
Acknowledging
Reply with any 2xx status (typically 200 OK with an empty body).
Anything outside the 200–299 range — including network errors and
timeouts — counts as a failure and triggers a retry.
Retries
Terminal callback delivery follows a configurable back-off schedule. The first attempt fires immediately on the terminal transition; if it fails (non-2xx, network, timeout), the dispatcher waits the next gap and re-fires:
The table below is the default when the shop has no custom callback retry intervals. A configured shop schedule replaces these gaps.
| Attempt | Wait before this attempt |
|---|---|
| 1 (initial) | — |
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 1 hour |
| 7 | 3 hours |
| 8 | 6 hours |
| 9 | 12 hours |
| 10 (final) | 24 hours |
With the default schedule, after the 10th attempt the auto-retry loop stops. The terminal
operation state is still queryable via
/v1/get_operation_state, and an
operator can replay the callback manually from the admin UI (every
admin resend is interleaved with the auto-schedule and shows up in the
same audit log).
The retry loop also short-circuits the moment any successful callback is recorded for the operation — including a manual admin resend that happened between auto-retries — so a webhook endpoint that the operator already replayed doesn't get hit again on the next scheduled attempt.
Every attempt — auto or manual — is recorded as a callback_attempts
row with the trigger (auto_initial, auto_retry,
manual_resend, manual_override), HTTP status, and error message
where applicable. Operators can see the full log per operation in the
admin UI.
The per-attempt HTTP timeout is 10 seconds. Make sure your handler can respond inside that window — do the heavy lifting asynchronously.
Idempotency
The same operation can produce multiple delivery attempts, and an authoritative
admin/provider reversal can move one operation through the same status more than
once (completed → failed → completed). The current webhook body does not
expose an event-generation identifier. Therefore do not use a permanent
(operation_id, status) tombstone: it would discard the later authoritative
completed snapshot.
Use these rules instead:
- serialize processing per
operation_id; - store the latest complete operation snapshot transactionally and make business effects converge from that snapshot rather than blindly adding another credit/debit;
- an exact retry of the same body may be acknowledged without repeating the already committed effect;
- after ambiguity or an apparent reversal, reconcile with
/v1/get_operation_statebefore applying irreversible external side effects.
This is a current contract limitation, not an undocumented event_version
field. A future additive webhook version can provide a stable transition
identity; clients must not assume it exists today.
Handler examples
Node.js / Express
app.post("/nspay/webhook", express.json(), async (req, res) => {
const op = req.body;
if (op.type !== "payment" && op.type !== "payout") {
return res.sendStatus(200);
}
// upsertOperationSnapshot serializes by operation_id and makes the local
// business state converge to this complete snapshot. Do not deduplicate
// permanently on operation_id + status: the same status can recur.
await upsertOperationSnapshot(op.operation_id, op);
res.sendStatus(200);
});Python / FastAPI
@app.post("/nspay/webhook")
async def nspay_webhook(op: dict):
# This operation-level upsert is transactional and convergent. The same
# status may occur again after an authoritative reversal.
await upsert_operation_snapshot(op["operation_id"], op)
return Response(status_code=200)Both examples commit an operation-level snapshot before acknowledging. Keep that transaction under the 10-second per-attempt window and enqueue slower downstream work from the same local transaction/outbox.
Verifying the sender
Today the gateway does not sign callback bodies. If you need to ensure the callback originated from nspay, restrict the receiving endpoint to the dispatcher's egress IP or front it with a shared-secret header you agree on with your account manager.
Signed callbacks are on the roadmap and will be opt-in per merchant.