Skip to main content

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.typeevent.nameEnvelopeMeaning
orderamount_paid_updatedOrder snapshotThe amount paid on the order changed (a payment was registered). Carries the full order state.
paymentstatus_updatedThin paymentA single payment's status changed.
refundstatus_updatedThin paymentA refund's status changed (same shape as payment, with event.type = refund).
orderstatus_updatedThin orderThe 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.

Branch on status, not event name

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.

Timestamps and Amounts
  • 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) or merchant_order_id.
  • Reconcile amount_paid against amount. Only fulfill when order.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-Id header.

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
}
FieldDescription
order.statusAlways cancelled (double "l") in this event. The Orders API and the order snapshot spell the same status canceled.
order.reasonWhy 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_testtrue 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 cancelled and canceled. If this webhook is resent, it arrives as a full order snapshot with the same event object and order.status = canceled, without reason.

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.

typeMeaning
paysera.fund-distributor.distribution.recipient.settledA recipient's share of a split payment settled
paysera.fund-distributor.distribution.failedA split distribution failed
paysera.fund-distributor.distribution.recipient.vop_checkedAn 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.returnedAn external recipient's bank sent back a payout that had already settled — that recipient was not paid after all
paysera.fund-distributor.distribution.recipient.resentA 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
}
}
FieldDescription
distribution_idThe held payout leg. Pass it back to the resolution endpoints.
beneficiary_idThe registered beneficiary, when resolvable; may be null (e.g. a recipient declared inline on the order).
beneficiary_iban / beneficiary_nameThe exact IBAN + name pair the check ran against.
amount / currencyThe held leg's amount (minor units).
verdictWEAK_MATCH (name does not match), PARTIAL_MATCH (close but not identical), or UNKNOWN_VERDICT (the check could not conclude — see error_code).
match_score_descriptionHuman-readable verdict, e.g. Weak Match; may be null.
error_codeFor UNKNOWN_VERDICT, why no conclusion was reached — e.g. NOT_IN_SCHEME, OUT_OF_SCOPE, DATA_ISSUE; otherwise null.
held_reasonWhy 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_requiredtrue — 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"
}
}
FieldDescription
distribution_idThe returned payout leg. This is the id you pass when re-sending the return.
beneficiary_idThe 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 / currencyWhat the payout leg carried when it was sent (minor units).
returned_amountWhat 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_reasonAn 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 null

Map 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.

One webhook per return

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
}
}
FieldDescription
distribution_idThe new payout leg. Its recipient.settled event carries the same id.
resend_of_distribution_idThe 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_idThe 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_nameThe recipient the money is going to now. Differs from the returned leg's whenever the details were corrected.
amount / currencyWhat 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_byMERCHANT (you, through the API or the portal) or SUPPORT (Paysera customer support). The channel only — never the person who pressed it.
beneficiary_correctedWhether this re-send goes to details that were rewritten, or repeats the original pair unchanged.
At most one re-send per return

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​

HeaderDescription
Content-Typeapplication/json
X-Paysera-SignatureHex-encoded HMAC of the raw body
X-Paysera-Signature-AlgHMAC-SHA256
X-Paysera-Created-AtUnix timestamp (seconds, UTC) of webhook generation
X-Paysera-Request-IdUnique request identifier (for tracing)
X-Paysera-Callback-IdUnique callback identifier (use for idempotency)
No X-Paysera-Event header

The 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
}
}