Skip to main content

Webhooks

Receive real-time payment notifications with secure webhook callbacks.

Webhooks (callbacks) notify your server about payment events in real-time. This guide covers setting up and handling webhooks.

Overview​

When a payment status changes, Paysera sends a POST request to your callback URL with event details. Your server must:

  1. Verify the signature
  2. Process the event
  3. Return appropriate HTTP status code
Timestamps and Amounts
  • All timestamps are Unix epoch (seconds since 1970-01-01)
  • All amounts are in minor currency units (e.g., cents)

Webhook Delivery​

Endpoint​

Webhooks are sent to the callback_url specified when creating the payment order.

Retry Policy​

If your endpoint doesn't return a 2xx status code, Paysera retries delivery with exponential backoff. A webhook is delivered up to 4 times in total (the initial attempt plus 3 retries):

AttemptWhen
1 (initial)Immediately
2 (1st retry)~1 hour after attempt 1
3 (2nd retry)~5 hours after attempt 2 (~6 hours after attempt 1)
4 (3rd retry)~25 hours after attempt 3 (~31 hours after attempt 1)

The delay grows exponentially: each retry waits 3600 × 5^(n-1) seconds, where n is the attempt that just failed (1 hour, then 5 hours, then 25 hours).

A 2xx response at any attempt stops further retries. After the 4th attempt fails, the webhook is marked as failed. Two special cases: returning 401 stops retries immediately (Paysera treats it as a signature/authentication rejection), and manually re-sending a webhook from the merchant portal performs a single delivery with no automatic retries.

Timeout​

Your endpoint should respond within ~10 seconds (Paysera allows up to 3 seconds to establish the connection and 10 seconds to read the response; a slower endpoint is treated as a failed attempt and retried). For longer processing, return 200 immediately and process asynchronously.

Webhook Payload​

Headers​

HeaderDescription
Content-Typeapplication/json
X-Paysera-SignatureHex-encoded HMAC of the raw request body (see Signature Verification)
X-Paysera-Signature-AlgSignature algorithm — HMAC-SHA256
X-Paysera-Created-AtUnix timestamp (seconds, UTC) when the webhook was generated
X-Paysera-Request-IdUnique request identifier (use for tracing and de-duplication)
X-Paysera-Callback-IdUnique callback identifier
No X-Paysera-Event header

The event name is inside the JSON body at event.name. There is no X-Paysera-Event HTTP header — route on the body field, not on headers.

Body Structure​

All fields are in snake_case. Order events (event.type = "order") deliver the full order snapshot at the moment the event fired:

{
"event": {
"name": "amount_paid_updated",
"type": "order"
},
"order": {
"paysera_order_id": "a6f2b8e3-5e5f-47d9-b13f-87ed2db2938a",
"merchant_order_id": "ORDER-12345",
"source": "https://myshop.paysera.net",
"amount": 2500,
"amount_paid": 2500,
"currency": "EUR",
"status": "paid",
"created_at": 1736433270,
"updated_at": 1736433570,
"merchant_data": [
{ "key": "internal_id", "value": "12345" }
],
"payment_links": [
{
"id": "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f",
"name": "Order #12345",
"created_at": 1736433270,
"updated_at": 1736433570,
"payer_name": "John Doe",
"payer_email": "john.doe@paysera.net",
"payments": [
{
"id": "p-1",
"method": "swedbank",
"status": "settled",
"original_amount": null,
"original_currency": null,
"payment_currency": "EUR",
"payment_amount": 2500,
"updated_at": 1736433570,
"payer_name": "John Doe",
"payer_email": "john.doe@paysera.net",
"payment_country": "LT",
"payer_ip_country": "LT",
"payer_country": "LT",
"purpose": "Order #12345"
}
]
}
]
}
}

Payment events (event.type = "payment" or "refund") use a compact thin envelope instead — a top-level version, minimal order identifiers, the affected payment block, and a timestamp:

{
"version": 1,
"event": { "type": "payment", "name": "status_updated" },
"order": {
"paysera_order_id": "a6f2b8e3-5e5f-47d9-b13f-87ed2db2938a",
"merchant_order_id": "ORDER-12345"
},
"payment": {
"id": "p-1",
"status": "settled",
"amount": 2500,
"currency": "EUR",
"method": "paysera"
},
"timestamp": 1736433570
}

Event Types​

Route on event.type, then act on the state in the payload (order.status for order events, payment.status for payment events). See the Webhook Events Reference for full payloads.

event.typeevent.nameDescription
orderamount_paid_updatedThe amount paid on the order changed; carries the full order snapshot
paymentstatus_updatedA payment's status changed (thin envelope)
refundstatus_updatedA refund's status changed (thin envelope)
note

Split-payment integrations additionally receive paysera.fund-distributor.distribution.* events (see the Webhook Events Reference). Treat any unknown event.name as a no-op rather than failing.

Signature Verification​

Always verify webhook signatures before processing to prevent fraud.

Verification Process​

  1. Read the signature from the X-Paysera-Signature header (hex-encoded).
  2. Read the raw request body bytes (do not re-serialize from a parsed object).
  3. Compute HMAC-SHA256(body, client_secret) and hex-encode it.
  4. Compare with a constant-time comparison.
Which secret to use

The signing secret is your OAuth Client Secret — the same client_secret shown in the API credentials section of your project's Integrations page and used to obtain access tokens. Store it as an environment variable (e.g., PAYSERA_CLIENT_SECRET) on your server.

<?php

function verifyWebhookSignature(string $payload, string $signature, string $secret): bool
{
$expectedSignature = hash_hmac('sha256', $payload, $secret);
return hash_equals($expectedSignature, $signature);
}

// In your webhook handler
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PAYSERA_SIGNATURE'] ?? '';
$secret = getenv('PAYSERA_CLIENT_SECRET');

if (!verifyWebhookSignature($payload, $signature, $secret)) {
http_response_code(401);
exit('Invalid signature');
}

// Process the webhook
$data = json_decode($payload, true);
// ...

Complete Webhook Handler​

PHP Example​

<?php

class PayseraWebhookHandler
{
private string $secret;

public function __construct(string $secret)
{
$this->secret = $secret;
}

public function handle(): void
{
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PAYSERA_SIGNATURE'] ?? '';

// Verify signature
if (!$this->verifySignature($payload, $signature)) {
http_response_code(401);
echo 'Invalid signature';
return;
}

// Parse payload
$data = json_decode($payload, true);
if (!$data) {
http_response_code(400);
echo 'Invalid payload';
return;
}

// Process event
try {
$this->processEvent($data);
http_response_code(200);
echo 'OK';
} catch (Exception $e) {
error_log('Webhook processing failed: ' . $e->getMessage());
http_response_code(500);
echo 'Processing failed';
}
}

private function verifySignature(string $payload, string $signature): bool
{
$expected = hash_hmac('sha256', $payload, $this->secret);
return hash_equals($expected, $signature);
}

private function processEvent(array $data): void
{
// Distribution events use a flat envelope with a top-level "type";
// order/payment events carry it under event.type.
$type = $data['event']['type'] ?? ($data['type'] ?? null);
$order = $data['order'] ?? null;

switch ($type) {
case 'order':
// Full order snapshot — branch on order.status, not the event name
$this->handleOrderSnapshot($order);
break;

case 'payment':
case 'refund':
// Thin payment/refund event — branch on payment.status
$this->handlePayment($order, $data['payment'] ?? []);
break;

default:
if (is_string($type) && str_starts_with($type, 'paysera.fund-distributor.')) {
// Split-payment distribution event
$this->handleDistribution($data);
} else {
error_log('Unhandled webhook type: ' . var_export($type, true));
}
}
}

private function handleOrderSnapshot(?array $order): void
{
// Only fulfill once the order is fully paid.
if (($order['status'] ?? null) === 'paid') {
error_log('Order paid: ' . ($order['merchant_order_id'] ?? ''));
// Fulfill order, send confirmation email, etc.
}
}

private function handlePayment(?array $order, array $payment): void
{
// React to $payment['status'] (settled, failed, rejected, cancelled, ...)
error_log("Payment {$payment['status']} for " . ($order['merchant_order_id'] ?? ''));
}

private function handleDistribution(array $event): void
{
// Split payments only — reconcile the recipient's share in $event['data']
error_log("Distribution {$event['status']} for order " . ($event['order_id'] ?? ''));
}
}

// Usage
$handler = new PayseraWebhookHandler(getenv('PAYSERA_CLIENT_SECRET'));
$handler->handle();

Resending Callbacks​

If your endpoint was temporarily unavailable, you can force a redelivery of a previously stored callback for debugging or reconciliation.

POST /checkout-engine/integration/v1/callbacks/{callbackId}/resend/{orderId}

Both callback_id and order_id must belong to your project. The request body is empty.

curl -X POST https://api.paysera.com/checkout-engine/integration/v1/callbacks/{callbackId}/resend/{orderId} \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Rate limited

This endpoint is limited to 10 requests per hour per project (shared with the merchant resend endpoint).

Response​

{
"id": "b2c3d4e5-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
"reference_id": "ORDER-12345",
"target_url": "https://example.com/webhook",
"status": "success",
"event": { "name": "order.status_updated", "type": "order" },
"payload": "{\"order\":{ … }}",
"max_attempts": 5,
"created_at": 1736433270,
"updated_at": 1736433600,
"attempts": [
{
"id": "e5f6a7b8-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"attempt_number": 1,
"attempted_at": 1736433600,
"response_code": 200,
"status": "success"
}
]
}

Response Fields​

FieldTypeDescription
idUUIDCallback identifier
reference_idstringReference of the related order
target_urlstringYour callback URL
statusstringpending, success or failed
eventobject{ name, type } of the event
payloadstringJSON string of the original payload
max_attemptsintegerMaximum delivery attempts
created_atintegerCreation Unix timestamp (seconds)
updated_atintegerLast update Unix timestamp (seconds)
attemptsarrayDelivery attempts (id, attempt_number, attempted_at, response_code, status)

Idempotency​

Webhooks may be delivered multiple times (retries, network glitches). Implement idempotent processing keyed on the X-Paysera-Callback-Id header — it uniquely identifies a single delivery attempt:

<?php

class IdempotentWebhookHandler
{
private PDO $db;

public function processEvent(array $data, string $callbackId): void
{
// Check if this callback ID was already processed
$stmt = $this->db->prepare(
'SELECT id FROM processed_webhooks WHERE callback_id = ?'
);
$stmt->execute([$callbackId]);

if ($stmt->fetch()) {
// Already processed, skip
return;
}

// Process the event
$this->doProcess($data);

// Mark as processed
$stmt = $this->db->prepare(
'INSERT INTO processed_webhooks (callback_id, processed_at) VALUES (?, NOW())'
);
$stmt->execute([$callbackId]);
}
}

// In the entry-point handler:
$callbackId = $_SERVER['HTTP_X_PAYSERA_CALLBACK_ID'] ?? '';

Testing Webhooks​

Local Development​

Use tunneling services to expose your local server:

# Using ngrok
ngrok http 8000

# Use the generated URL as callback_url
# https://abc123.ngrok.io/webhooks/paysera

Webhook Testing Tool​

Use webhook.site to inspect webhook payloads:

  1. Get a unique URL from webhook.site
  2. Use it as your callback_url
  3. Trigger a payment
  4. Inspect the received payload

Response Codes​

CodeMeaningPaysera Action
200-299SuccessNo retry
401UnauthorizedNo retry (logs error)
4xxClient errorNo retry
5xxServer errorRetry with backoff
TimeoutNo response in 30sRetry with backoff