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). |
order | status_updated | Thin order | The order was canceled. See Order Canceled Event. |
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.reference_updated event, and order /
status_updated is sent only when an order is canceled. Becoming paid reaches 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".
Order Canceled Event​
event.type = "order", event.name = "status_updated". Sent when an order is canceled:
only a pending_payment order with nothing paid can be, by the merchant in the Paysera
Merchant Portal or by Paysera support. Same thin envelope as the payment event, with the
status and reason inside the order block.
{
"version": 1,
"event": {
"type": "order",
"name": "status_updated"
},
"order": {
"paysera_order_id": "019ed03a-84f0-7ba0-874a-f7473738875b",
"merchant_order_id": "ORDER-12345",
"status": "cancelled",
"reason": "canceled_by_merchant"
},
"timestamp": 1736433570
}
| Field | Description |
|---|---|
order.status | Always cancelled (double "l") in this event. The Orders API and the order snapshot spell the same status canceled. |
order.reason | Why the order was canceled: canceled_by_merchant or canceled_by_admin. Two further values, expired_ttl and project_deleted, are defined but not in use yet. Omitted when no reason is recorded; treat unknown values as a generic cancel. |
order.is_test | true for orders of a project in Test Mode; omitted for live orders. |
Actions to take:
- Mark the order canceled on your side and stop offering its payment link: active links are canceled with the order.
- Match both
cancelledandcanceled. If this webhook is resent, it arrives as a full order snapshot with the sameeventobject andorder.status=canceled, withoutreason.
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 |
paysera.fund-distributor.distribution.recipient.resent | A returned payout was sent again, as a new payout leg |
{
"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": {
"distribution_id": "019efc90-1c44-7a02-8b71-2ce9f0a71b55",
"beneficiary_id": "019e2a8a-6dcc-7245-a24d-23e8561f8fda",
"amount": 4000,
"currency": "EUR"
}
}
data.distribution_id is the payout leg that settled — the same id the leg's other events carry,
and the one to match against a re-sent payout.
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. These are the distribution service's own codes, not the error_code vocabulary the payment read publishes (payout_returned, destination_not_allowed, payout_failed).
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). This is the distribution service's own name for the hold, not the held_reason vocabulary the payment read publishes (account_unusable, name_mismatch, on_hold). |
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. The money is back on your settlement account and this leg is final, but the
return itself can be answered: re-send it
to corrected recipient details and a new leg is created for 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": {
"distribution_id": "019efc71-f2b0-7d51-8f68-1a9f865dcf03",
"beneficiary_id": "770e8400-e29b-41d4-a716-446655440222",
"amount": 4000,
"returned_amount": 4000,
"currency": "EUR",
"return_reason": "AC04"
}
}
| Field | Description |
|---|---|
distribution_id | The returned payout leg. This is the id you pass when re-sending the return. |
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 | What the payout leg carried when it was sent (minor units). |
returned_amount | What actually came back (minor units). Equal to amount on a full return, smaller when the bank returned only part of the payout — read this field rather than amount when you reconcile. null when the returning bank gave no usable figure: it sent none, sent something we could not read, or sent an amount in another currency. A null does not mean the whole payout came back — a full return always carries a figure equal to amount. Treat null as "amount unknown", not as a full reversal, and check the payment before you reconcile. The field is always present. |
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. |
returned_amount can be nullMap it to a nullable numeric type. An integration that expects a number here will fail to deserialize the body, and the webhook will keep being retried until the retry policy is exhausted — a silent outage for that endpoint.
Actions to take: stop treating that recipient as paid for this payment, then
re-send the return with the
corrected IBAN and name. That both pays the recipient and updates the details you hold for
them, so later orders go to the corrected account. Whether you may re-send it yourself
depends on return_reason — the four codes that a corrected name and IBAN actually fix
(AC01, AC04, AC06, RR03) are self-service; every other reason is a Paysera support
case, and the endpoint answers 422 payout_not_resendable.
Do not reach for the name-check resolution endpoints here — they rewrite a leg that is still held and has never reached the bank, which a returned leg has. A return is always answered with a new leg, never by editing the old one.
A return produces this event and nothing else. No distribution.failed event accompanies
it, even though the leg itself ends in a failed state.
Re-sent Payout (recipient.resent)​
Sent when a returned payout is sent again — by you through the API, in the Paysera Checkout
portal, or by Paysera customer support for a return you cannot fix yourself. The new leg is
verified from scratch (name check and screening) before it is dispatched, so this event says
the re-send was accepted, not that the money has arrived. Settlement follows as an ordinary
recipient.settled event carrying the same distribution_id, which is what ties the two
together. Like every settled event it is only sent for a registered beneficiary, so a
re-send to an account that is not one of your registered recipients settles silently.
{
"id": "evt_019efc91-8a24-7b53-9d2a-88e3c62c9f4e",
"type": "paysera.fund-distributor.distribution.recipient.resent",
"created": 1736436000,
"payment_id": "019eba8b-8c78-7d2d-9153-640e6a9e1c8a",
"order_id": "019eba8a-ffa4-7180-a47c-319fa865dcf0",
"status": "resent",
"data": {
"distribution_id": "019efc90-1c44-7a02-8b71-2ce9f0a71b55",
"resend_of_distribution_id": "019efc71-f2b0-7d51-8f68-1a9f865dcf03",
"beneficiary_id": "770e8400-e29b-41d4-a716-446655440222",
"beneficiary_iban": "DE89370400440532013000",
"beneficiary_name": "Partner Handels GmbH",
"amount": 4000,
"currency": "EUR",
"initiated_by": "MERCHANT",
"beneficiary_corrected": true
}
}
| Field | Description |
|---|---|
distribution_id | The new payout leg. Its recipient.settled event carries the same id. |
resend_of_distribution_id | The returned leg this answers — the same id the recipient.returned webhook carried. Use it to close that row on your side. The re-send endpoint returns the same value as returned_distribution_id; the two names are one id. |
beneficiary_id | The registered beneficiary the new leg pays. null when that account is not (or not yet) a registered beneficiary on the project — a re-send to a corrected IBAN is exactly the case where your registry entry has just moved. |
beneficiary_iban / beneficiary_name | The recipient the money is going to now. Differs from the returned leg's whenever the details were corrected. |
amount / currency | What is being sent again (minor units). Under a partial return this is less than the returned leg carried, so render it from this field rather than from the original leg. |
initiated_by | MERCHANT (you, through the API or the portal) or SUPPORT (Paysera customer support). The channel only — never the person who pressed it. |
beneficiary_corrected | Whether this re-send goes to details that were rewritten, or repeats the original pair unchanged. |
A return can be answered once. Repeating the call answers ALREADY_RESENT with the leg
that already exists — or CREATED when it revives a re-send that had failed without
reaching the bank, which is still that same leg. Either way no second recipient.resent
webhook is sent.
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 or order canceled event — 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.
} elseif (in_array($order['status'] ?? null, ['cancelled', 'canceled'], true)) {
// Thin cancel event spells it "cancelled"; a resent snapshot spells it "canceled"
$this->orders->markCanceled($order['paysera_order_id']);
}
}
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
}
}