Webhook Events Reference
Complete reference of all webhook events sent by Paysera Checkout.
Events​
Paysera Checkout sends a small, fixed set of webhook events. Each event carries an
event object with a type and a name. Route on event.type, then act on the
state inside the payload (order.status for order events, payment.status for
payment events) — do not depend on a rich event-name taxonomy.
event.type | event.name | Envelope | Meaning |
|---|---|---|---|
order | amount_paid_updated | Order snapshot | The amount paid on the order changed (a payment was registered). Carries the full order state. |
payment | status_updated | Thin payment | A single payment's status changed. |
refund | status_updated | Thin payment | A refund's status changed (same shape as payment, with event.type = refund). |
Split-payment integrations additionally receive distribution events
(paysera.fund-distributor.distribution.*), which use a different envelope — see
Distribution Events.
There is no order.created / order.status_updated / order.reference_updated
event. Order lifecycle changes (including becoming paid) reach you through the
order snapshot event and the thin payment event. Treat any unknown event.name
as a no-op rather than failing.
- All timestamps are Unix epoch (seconds, UTC)
- All amounts are in minor currency units (e.g., cents)
- All payload fields are snake_case
Order Snapshot Event​
event.type = "order". The body has two top-level keys, event and order, and
delivers the full order snapshot at the moment the event fired — including every
payment link under order.payment_links[] and each link's payments under
order.payment_links[].payments[]. Branch on order.status.
{
"event": {
"name": "amount_paid_updated",
"type": "order"
},
"order": {
"paysera_order_id": "019ed03a-84f0-7ba0-874a-f7473738875b",
"merchant_order_id": "ORDER-12345",
"source": "paysera-marketplace",
"amount": 2500,
"amount_paid": 2500,
"currency": "EUR",
"status": "paid",
"created_at": 1736433270,
"updated_at": 1736433570,
"merchant_data": [],
"payment_links": [
{
"id": "019ed03a-8591-7197-9aad-d9c029286b77",
"name": "Order #12345",
"created_at": 1736433270,
"updated_at": 1736433570,
"payer_name": "Jonas Jonaitis",
"payer_email": "jonas.jonaitis@example.com",
"payments": [
{
"id": "019ed03a-8f12-7503-8369-9c01999bf6cb",
"method": "paysera",
"status": "settled",
"original_amount": null,
"original_currency": null,
"payment_currency": "EUR",
"payment_amount": 2500,
"updated_at": 1736433570,
"payer_name": "Jonas Jonaitis",
"payer_email": "jonas.jonaitis@example.com",
"payment_country": "LT",
"payer_ip_country": "LT",
"payer_country": "LT",
"purpose": "Payment for order #12345"
}
]
}
]
}
}
Actions to take:
- Look the order up by
paysera_order_id(stable) ormerchant_order_id. - Reconcile
amount_paidagainstamount. Only fulfill whenorder.status == "paid"(equivalently,amount_paid >= amount) — a snapshot can arrive for a partial payment. - The same callback can arrive more than once; de-duplicate on the
X-Paysera-Callback-Idheader.
Thin Payment Event​
event.type = "payment" (or "refund"). A compact, payment-centric envelope — not
the full order snapshot. It includes a top-level version, the minimal order
identifiers, the affected payment block, and a timestamp.
{
"version": 1,
"event": {
"type": "payment",
"name": "status_updated"
},
"order": {
"paysera_order_id": "019ed03a-84f0-7ba0-874a-f7473738875b",
"merchant_order_id": "ORDER-12345"
},
"payment": {
"id": "019ed03a-8f12-7503-8369-9c01999bf6cb",
"status": "settled",
"amount": 2500,
"currency": "EUR",
"method": "paysera"
},
"timestamp": 1736433570
}
Actions to take:
- Branch on
payment.status(see Payment Statuses). - Refunds use the identical shape with
event.type = "refund".
Distribution Events​
Only sent to split-payment integrations. These use a flat envelope (no nested
event object); the event kind is the top-level type.
type | Meaning |
|---|---|
paysera.fund-distributor.distribution.recipient.settled | A recipient's share of a split payment settled |
paysera.fund-distributor.distribution.failed | A split distribution failed |
paysera.fund-distributor.distribution.recipient.vop_checked | An external recipient's name did not fully match their IBAN (Verification of Payee) — the payout is held and requires your action |
paysera.fund-distributor.distribution.recipient.returned | An external recipient's bank sent back a payout that had already settled — that recipient was not paid after all |
{
"id": "evt_019eba8f-f582-71ef-b404-5a20b51b8e3e",
"type": "paysera.fund-distributor.distribution.recipient.settled",
"created": 1736433570,
"payment_id": "019eba8b-8c78-7d2d-9153-640e6a9e1c8a",
"order_id": "019eba8a-ffa4-7180-a47c-319fa865dcf0",
"status": "settled",
"data": {
"beneficiary_id": "019e2a8a-6dcc-7245-a24d-23e8561f8fda",
"amount": 4000,
"currency": "EUR"
}
}
For distribution.failed the same envelope carries "status": "failed" and a different data object:
"data": {
"error_code": "SETTLEMENT_FAILED",
"reason": "..."
}
data.error_code names the failure category — for example SETTLEMENT_FAILED, SPLIT_CONFIG_NOT_FOUND, PARTNER_NOT_FOUND, PARTNER_ACCOUNT_NOT_EVP, MERCHANT_NOT_EVP, INTERMEDIARY_ACCOUNT_MISSING, INTERMEDIARY_NOT_EVP, UNSUPPORTED_PAYMENT_METHOD, or, for external-IBAN recipients, EXTERNAL_TRANSFER_REJECTED and EXTERNAL_ESCALATION_EXPIRED. New codes may be added over time — treat unknown values as a generic failure. data.reason is free-text detail, not a stable code.
A returned payout is the one exception: it is reported by recipient.returned alone, never as a distribution.failed event.
Recipient Name Check (recipient.vop_checked)​
Sent only for external (non-Paysera) recipients.
Before an external SEPA payout is sent, the recipient's stored name is verified
against the account holder of their IBAN (the SEPA Verification of Payee scheme).
When the check does not return a strong match, the payout leg is held and this
event tells you to act:
{
"id": "evt_019efc72-1a04-7c1e-9a5f-2b51b8e3f404",
"type": "paysera.fund-distributor.distribution.recipient.vop_checked",
"created": 1736433570,
"payment_id": "019eba8b-8c78-7d2d-9153-640e6a9e1c8a",
"order_id": "019eba8a-ffa4-7180-a47c-319fa865dcf0",
"status": "held",
"data": {
"distribution_id": "019efc71-f2b0-7d51-8f68-1a9f865dcf03",
"beneficiary_id": "770e8400-e29b-41d4-a716-446655440222",
"beneficiary_iban": "DE89370400440532013000",
"beneficiary_name": "Partner GmbH",
"amount": 4000,
"currency": "EUR",
"verdict": "WEAK_MATCH",
"match_score_description": "Weak Match",
"error_code": null,
"held_reason": "EXTERNAL_VOP_MISMATCH",
"action_required": true
}
}
| Field | Description |
|---|---|
distribution_id | The held payout leg. Pass it back to the resolution endpoints. |
beneficiary_id | The registered beneficiary, when resolvable; may be null (e.g. a recipient declared inline on the order). |
beneficiary_iban / beneficiary_name | The exact IBAN + name pair the check ran against. |
amount / currency | The held leg's amount (minor units). |
verdict | WEAK_MATCH (name does not match), PARTIAL_MATCH (close but not identical), or UNKNOWN_VERDICT (the check could not conclude — see error_code). |
match_score_description | Human-readable verdict, e.g. Weak Match; may be null. |
error_code | For UNKNOWN_VERDICT, why no conclusion was reached — e.g. NOT_IN_SCHEME, OUT_OF_SCOPE, DATA_ISSUE; otherwise null. |
held_reason | Why the leg is stopped (EXTERNAL_VOP_MISMATCH). |
action_required | true — the payout stays held until you confirm or correct the recipient. |
Actions to take: review the pair, then either confirm (pay anyway to the name/IBAN as-is) or correct the recipient's IBAN/name — via the resolution endpoints or in the Paysera Checkout portal (the held payout is flagged on the payment's details). A corrected recipient is re-verified; if the new pair still does not match strongly, this event fires again with the fresh verdict. An unchanged verdict is never re-sent — you get exactly one event per new check result.
Returned Payout (recipient.returned)​
Sent only for external (non-Paysera) recipients.
A SEPA credit transfer that has already settled can still be sent back by the
recipient's bank days later — for example a closed account, an IBAN that is not theirs,
or a name the bank will not accept. The return_reason field carries the bank's own
code for it. That recipient's share does not reach them, and the payout leg is final —
nothing re-sends it:
{
"id": "evt_019efc80-3b17-7a42-8c19-77d2b51b8e3e",
"type": "paysera.fund-distributor.distribution.recipient.returned",
"created": 1736433570,
"payment_id": "019eba8b-8c78-7d2d-9153-640e6a9e1c8a",
"order_id": "019eba8a-ffa4-7180-a47c-319fa865dcf0",
"status": "returned",
"data": {
"beneficiary_id": "770e8400-e29b-41d4-a716-446655440222",
"amount": 4000,
"currency": "EUR",
"return_reason": "AC04"
}
}
| Field | Description |
|---|---|
beneficiary_id | The registered beneficiary whose payout came back. null when that account is no longer a registered beneficiary on the project — for example after you corrected or deleted it, and always for the merchant's own leg. |
amount / currency | The amount that came back (minor units). |
return_reason | An ISO 20022 external return reason code — four upper-case characters, e.g. AC04 for a closed account. The full set is the return reason code set (ExternalReturnReason1Code) in the ISO 20022 External Code Sets, which ISO updates over time; treat a code you do not recognise as a reason to look at the payout, not as a reason to fail. null when the bank sent no reason, or sent free text instead of a code. The field is always present. |
Actions to take: stop treating that recipient as paid for this payment. Correct the IBAN and the name you hold for them, so later payouts reach them. A returned leg is final — no endpoint repeats a returned payout. You cannot use the resolution endpoints here: they only work while a leg is held.
A return produces this event and nothing else. No distribution.failed event accompanies
it, even though the leg itself ends in a failed state.
Webhook Headers​
| Header | Description |
|---|---|
Content-Type | application/json |
X-Paysera-Signature | Hex-encoded HMAC of the raw body |
X-Paysera-Signature-Alg | HMAC-SHA256 |
X-Paysera-Created-At | Unix timestamp (seconds, UTC) of webhook generation |
X-Paysera-Request-Id | Unique request identifier (for tracing) |
X-Paysera-Callback-Id | Unique callback identifier (use for idempotency) |
X-Paysera-Event headerThe event identity is in the JSON body at event.type / event.name (or the top-level
type for distribution events). There is no X-Paysera-Event HTTP header — do not
branch on headers for the event.
Complete Handler Example​
PHP​
<?php
class WebhookHandler
{
private string $secret; // Your OAuth Client Secret (used for webhook signing)
private OrderRepository $orders;
public function handle(): void
{
// Read raw body and headers
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PAYSERA_SIGNATURE'] ?? '';
$callbackId = $_SERVER['HTTP_X_PAYSERA_CALLBACK_ID'] ?? '';
// Verify signature
if (!$this->verifySignature($payload, $signature)) {
http_response_code(401);
return;
}
// Idempotency check — skip if this callback ID was already processed
if ($this->orders->callbackProcessed($callbackId)) {
http_response_code(200);
return;
}
$data = json_decode($payload, true);
// Distribution events use a flat envelope with a top-level "type"
$type = $data['event']['type'] ?? ($data['type'] ?? null);
match (true) {
// Order snapshot — branch on order.status, not the event name
$type === 'order' => $this->handleOrderSnapshot($data['order']),
// Thin payment / refund event
$type === 'payment', $type === 'refund' => $this->handlePayment($data['order'], $data['payment']),
// Split-payment distribution events
str_starts_with((string) $type, 'paysera.fund-distributor.') => $this->handleDistribution($data),
default => error_log("Unknown webhook type: $type"),
};
$this->orders->markCallbackProcessed($callbackId);
http_response_code(200);
echo 'OK';
}
private function verifySignature(string $payload, string $signature): bool
{
$expected = hash_hmac('sha256', $payload, $this->secret);
return hash_equals($expected, $signature);
}
private function handleOrderSnapshot(array $order): void
{
if (($order['status'] ?? null) === 'paid') {
$this->orders->markPaid($order['paysera_order_id']);
// Trigger fulfillment, send confirmation email, etc.
}
}
private function handlePayment(array $order, array $payment): void
{
// React to payment.status for this order
}
private function handleDistribution(array $event): void
{
// Split payments only — reconcile the recipient's share
}
}