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 with exponential backoff.

AttemptDelay
1st retry~5 seconds
2nd retry~60 seconds
3rd retry~3600 seconds (1 hour)

After all retries fail, the webhook is marked as failed.

Timeout​

Your endpoint should respond within ~30 seconds. 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. Payload mirrors the full order snapshot at the moment the event fired.

{
"event": {
"name": "order.status_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"
}
]
}
]
}
}

Event Types​

The event.name field carries one of the following values. See Webhook Events Reference for a per-event description and trigger.

EventTypeDescription
order.createdorderOrder was created
order.status_updatedorderOrder status changed (e.g., to paid)
order.reference_updatedorderOrder reference was changed
order.amount_updatedorderOrder total amount was changed
order.amount_paid_updatedorderOrder's amount paid was updated
order.payment_link.expired_at_updatedorderA payment link's expiration time changed
note

Additional payment-link sub-events exist internally (created, name/amount/language/receiver/payer/status updates). The events above are the ones reliably delivered to merchant webhook subscribers — branch logic on event.name and ignore unknown event names 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, project_webhook_secret) and hex-encode it.
  4. Compare with a constant-time comparison.
Which secret to use

The signing secret is the project webhook secret configured for your integration project — not your OAuth client_secret. They are separate credentials. Store the webhook secret as an environment variable (e.g., PAYSERA_PROJECT_WEBHOOK_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_PROJECT_WEBHOOK_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
{
$eventName = $data['event']['name'] ?? null;
$order = $data['order'] ?? null;

if (!$eventName || !$order) {
throw new Exception('Missing event or order data');
}

$merchantOrderId = $order['merchant_order_id'] ?? null;
$status = $order['status'] ?? null;

switch ($eventName) {
case 'order.created':
$this->handleOrderCreated($merchantOrderId, $order);
break;

case 'order.status_updated':
$this->handleOrderStatusUpdated($merchantOrderId, $status, $order);
break;

case 'order.amount_paid_updated':
$this->handleOrderAmountPaidUpdated($merchantOrderId, $order);
break;

case 'order.amount_updated':
case 'order.reference_updated':
case 'order.payment_link.expired_at_updated':
// Other order-scoped updates — handle if relevant
error_log("Order updated: $eventName ($merchantOrderId)");
break;

default:
// Unknown / future event — log and ignore
error_log("Unhandled event: $eventName");
}
}

private function handleOrderCreated(string $merchantOrderId, array $order): void
{
error_log("Order created: $merchantOrderId");
}

private function handleOrderStatusUpdated(string $merchantOrderId, string $status, array $order): void
{
// Branch on $status — 'paid' is the most common transition you fulfill on.
if ($status === 'paid') {
// Fulfill order, send confirmation email, etc.
error_log("Order paid: $merchantOrderId");
}
}

private function handleOrderAmountPaidUpdated(string $merchantOrderId, array $order): void
{
// Partial payment received; check $order['amount_paid'] vs $order['amount'].
error_log("Amount paid updated: $merchantOrderId");
}
}

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

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