Webhooks
Operation status notifications, delivery retries, and event deduplication.
When an operation enters a subscribed status, InOut POSTs the
operation snapshot to the callback URL configured for the shop
that owns the operation. Use /v1/get_operation_state to reconcile after a
missed or ambiguous notification.
When a callback fires
By default (an empty callback-status filter) a callback is sent for the
terminal statuses completed, failed, rejected, and cancelled, plus the
intermediate statuses awaiting_confirmation, pending_confirmation, and
manual_check. A shop may
configure a strict callback-status filter; when it is non-empty, only listed
statuses are delivered — this applies to the intermediate statuses too, so a
shop that narrows the filter must list awaiting_confirmation /
pending_confirmation / manual_check
explicitly to keep receiving them. 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 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 also sent when an uploaded receipt enters
pending_confirmation. This means the receipt was accepted and automatic
validation is in progress; do not ask the end user to upload it again. It is a
one-shot, best-effort intermediate callback and does not replace the eventual
terminal callback.
A callback is also sent when a payment enters manual_check — an
intermediate status (not terminal) that fires when an uploaded receipt is
flagged for manual review (for example, our antifraud found the receipt
suspicious). The payment is still in flight; you may surface it as "under review"
and, if you automate
around it, react to the flag (e.g. alert an operator). A terminal callback
(completed / failed) still fires later once review resolves. Like
the other receipt states, it is a best-effort intermediate: attempted once, it
does not use the terminal retry schedule, and it is subject to the shop
callback-status filter (delivered under the default empty filter; otherwise
opt in by listing manual_check). If your integration does not recognise the
value, ignore it — it never replaces the terminal callback. Receipt replacement
rules are described in Confirm payment.
Every manual_check snapshot includes manual_check_reason. Its values are
potential_fake (the receipt may be forged or otherwise suspicious) and
not_terminal (the receipt does not prove a completed transfer: the shown
transfer is still in progress/non-terminal, the uploaded file is not a PDF
and could not be validated, or automatic validation was temporarily
unavailable). The
field is omitted for all other statuses. If the reason is unknown, the API
returns potential_fake.
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 by InOut and able to respond within 10 seconds.
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.
Request
POST <callback_url>
Content-Type: application/jsonBody — a public snapshot of the operation:
{
"operation_id": "f6c2e7d4-3e5b-4f1f-8a02-9d6c7e8a1b22",
"event_seq": 3,
"type": "payment",
"status": "completed",
"method": "WT_RUB_PHONE",
"expected_amount": "1500.00",
"actual_amount": "1500.00",
"currency": "RUB",
"actual_currency": "RUB",
"settlement_amount": "1470.00",
"settlement_currency": "RUB",
"commission_amount": "30.00",
"payout_destination": {
"phone": {
"phone_number": "+79991234567",
"name": "Ivan Ivanov",
"bank": "sber"
}
}
}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
InOut 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.
external_confirmation is present only while status is
awaiting_confirmation. It describes the action currently required from the
merchant and is omitted from every other intermediate or terminal callback,
including pending_confirmation, manual_check, and cancelled.
settlement_amount is the amount that actually moved on your balance, net of
the InOut 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 InOut 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. Terminal events and manual resends retry according
to the schedule below; automatic intermediate receipt notifications are attempted
once. Redirects are not
followed: a 3xx reply is a failed attempt, so configure the final callback
URL directly.
Retries
Terminal callbacks and manual resends follow a configurable back-off schedule. Events for one operation are delivered in order: an event waiting for a retry blocks later events for that operation until it succeeds or exhausts its attempts. Different operations can be delivered concurrently. The first attempt starts when the event reaches the front of its queue; on failure, InOut waits the next gap and retries.
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 enqueue a manual resend from the admin UI. A manual resend is a
new snapshot event with a new event_seq, in the same operation queue. It does
not overtake or cancel an earlier event's retry. Delivery history is available
to operators in the admin UI.
Each event keeps the snapshot captured when it was queued. Automatic retries
send the same body and event_seq, even if the operation has since changed.
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). event_seq is a monotonically
increasing event number within one operation. It is not a global sequence, and
clients must not require consecutive numbers.
- Serialize processing per
operation_id. In one transaction, compareevent_seqwith the last applied sequence, update the snapshot and its business effects, and save the new sequence. - Acknowledge an equal or lower sequence with
2xxwithout applying it again. - Do not deduplicate permanently by
(operation_id, status): the same status can recur with a higher sequence. - A higher sequence can also be a manual resend of the same state. Apply the difference from your stored snapshot, not another unconditional credit/debit.
- If
event_seqis missing, reconcile the operation with/v1/get_operation_stateinstead of assigning a guessed sequence. Do not reset your last stored sequence during reconciliation.
After ambiguity or an apparent reversal, reconcile the current operation state before applying irreversible external side effects.
Handler examples
Node.js / Express
app.post("/payments/webhook", express.json(), async (req, res) => {
const op = req.body;
if (op.type !== "payment" && op.type !== "payout") {
return res.sendStatus(200);
}
if (op.event_seq == null) {
await reconcileOperation(op.operation_id);
} else {
// Application helper: atomically ignore event_seq <= last applied,
// apply the snapshot difference, and save event_seq for this operation.
await applySnapshotIfNewer(op.operation_id, op.event_seq, op);
}
res.sendStatus(200);
});Python / FastAPI
@app.post("/payments/webhook")
async def payment_webhook(op: dict):
if op.get("event_seq") is None:
await reconcile_operation(op["operation_id"])
else:
# Application helper: compare sequence, apply snapshot differences,
# and store the sequence in one transaction per operation.
await apply_snapshot_if_newer(op["operation_id"], op["event_seq"], op)
return Response(status_code=200)The helper functions above are application code you implement with your own database and authenticated API client. Commit the snapshot and sequence before acknowledging; return an error if processing fails so terminal events can retry. Keep processing under the 10-second timeout and enqueue slower work from the same transaction.
Verifying the sender
InOut does not sign callback bodies. To verify the sender, restrict the receiving endpoint to the InOut sending IP addresses or use a shared-secret header agreed with your account manager.