Webhooks
How CasherMatch notifies the merchant when an operation reaches a terminal state.
When an operation transitions into a terminal status, CasherMatch 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 (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 and needs nothing further from you
or the end user; 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.
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, or the uploaded file is not a PDF
and could not be validated). The
field is additive and omitted for all other statuses. If an older provider does
not send a reason, it is conservatively exposed as 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 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",
"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"
}
}
}Internal provider and routing identifiers are never included in merchant callbacks or public API 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
CasherMatch 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 CasherMatch 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 CasherMatch 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("/payments/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("/payments/webhook")
async def payment_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 CasherMatch, 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.