nspay

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 (or http:// 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/json

Body — 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 200299 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.

AttemptWait before this attempt
1 (initial)
230 seconds
31 minute
45 minutes
530 minutes
61 hour
73 hours
86 hours
912 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_state before 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.

On this page