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

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

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

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
{
"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
}
}
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).
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. 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"
}
}
FieldDescription
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 / currencyThe amount that came back (minor units).
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.

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.

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.

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