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:
- Verify the signature
- Process the event
- Return appropriate HTTP status code
- 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):
| Attempt | When |
|---|---|
| 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​
| Header | Description |
|---|---|
Content-Type | application/json |
X-Paysera-Signature | Hex-encoded HMAC of the raw request body (see Signature Verification) |
X-Paysera-Signature-Alg | Signature algorithm — HMAC-SHA256 |
X-Paysera-Created-At | Unix timestamp (seconds, UTC) when the webhook was generated |
X-Paysera-Request-Id | Unique request identifier (use for tracing and de-duplication) |
X-Paysera-Callback-Id | Unique callback identifier |
X-Paysera-Event headerThe 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.type | event.name | Description |
|---|---|---|
order | amount_paid_updated | The amount paid on the order changed; carries the full order snapshot |
payment | status_updated | A payment's status changed (thin envelope) |
refund | status_updated | A refund's status changed (thin envelope) |
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​
- Read the signature from the
X-Paysera-Signatureheader (hex-encoded). - Read the raw request body bytes (do not re-serialize from a parsed object).
- Compute
HMAC-SHA256(body, client_secret)and hex-encode it. - Compare with a constant-time comparison.
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
- JavaScript
- Python
- Kotlin
- Go
- C#
<?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);
// ...
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Express.js handler
app.post('/webhooks/paysera', express.raw({ type: 'application/json' }), (req, res) => {
const payload = req.body.toString();
const signature = req.headers['x-paysera-signature'];
const secret = process.env.PAYSERA_CLIENT_SECRET;
if (!verifyWebhookSignature(payload, signature, secret)) {
return res.status(401).send('Invalid signature');
}
const data = JSON.parse(payload);
// Process the webhook
// ...
res.status(200).send('OK');
});
import hmac
import hashlib
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
expected_signature = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, signature)
# Flask handler
@app.route('/webhooks/paysera', methods=['POST'])
def handle_webhook():
payload = request.get_data()
signature = request.headers.get('X-Paysera-Signature', '')
secret = os.environ['PAYSERA_CLIENT_SECRET']
if not verify_webhook_signature(payload, signature, secret):
return 'Invalid signature', 401
data = request.get_json()
# Process the webhook
# ...
return 'OK', 200
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import org.springframework.web.bind.annotation.*
import org.springframework.http.ResponseEntity
fun verifyWebhookSignature(payload: ByteArray, signature: String, secret: String): Boolean {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
val expectedSignature = mac.doFinal(payload).joinToString("") { "%02x".format(it) }
return expectedSignature == signature
}
// Spring Boot handler
@RestController
class WebhookController {
@PostMapping("/webhooks/paysera")
fun handleWebhook(
@RequestBody payload: ByteArray,
@RequestHeader("X-Paysera-Signature") signature: String
): ResponseEntity<String> {
val secret = System.getenv("PAYSERA_CLIENT_SECRET")
if (!verifyWebhookSignature(payload, signature, secret)) {
return ResponseEntity.status(401).body("Invalid signature")
}
val data = String(payload)
// Process the webhook using Jackson or kotlinx.serialization
// ...
return ResponseEntity.ok("OK")
}
}
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
func verifyWebhookSignature(payload []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expectedSignature := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expectedSignature), []byte(signature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
signature := r.Header.Get("X-Paysera-Signature")
secret := os.Getenv("PAYSERA_CLIENT_SECRET")
if !verifyWebhookSignature(payload, signature, secret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Process the webhook using encoding/json
// ...
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func main() {
http.HandleFunc("/webhooks/paysera", webhookHandler)
http.ListenAndServe(":8080", nil)
}
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
public static class WebhookSignature
{
public static bool Verify(byte[] payload, string signature, string secret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(payload);
var expectedSignature = Convert.ToHexString(hash).ToLower();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expectedSignature),
Encoding.UTF8.GetBytes(signature)
);
}
}
// ASP.NET Core controller
[ApiController]
[Route("webhooks")]
public class WebhookController : ControllerBase
{
[HttpPost("paysera")]
public async Task<IActionResult> HandleWebhook()
{
using var reader = new StreamReader(Request.Body);
var payload = await reader.ReadToEndAsync();
var payloadBytes = Encoding.UTF8.GetBytes(payload);
var signature = Request.Headers["X-Paysera-Signature"].ToString();
var secret = Environment.GetEnvironmentVariable("PAYSERA_CLIENT_SECRET");
if (!WebhookSignature.Verify(payloadBytes, signature, secret))
{
return Unauthorized("Invalid signature");
}
// Process the webhook using System.Text.Json
// ...
return Ok("OK");
}
}
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"
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​
| Field | Type | Description |
|---|---|---|
id | UUID | Callback identifier |
reference_id | string | Reference of the related order |
target_url | string | Your callback URL |
status | string | pending, success or failed |
event | object | { name, type } of the event |
payload | string | JSON string of the original payload |
max_attempts | integer | Maximum delivery attempts |
created_at | integer | Creation Unix timestamp (seconds) |
updated_at | integer | Last update Unix timestamp (seconds) |
attempts | array | Delivery 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:
- Get a unique URL from webhook.site
- Use it as your callback_url
- Trigger a payment
- Inspect the received payload
Response Codes​
| Code | Meaning | Paysera Action |
|---|---|---|
| 200-299 | Success | No retry |
| 401 | Unauthorized | No retry (logs error) |
| 4xx | Client error | No retry |
| 5xx | Server error | Retry with backoff |
| Timeout | No response in 30s | Retry with backoff |
Related Documentation​
- Payment Orders - Create orders with callback URLs
- Payment Statuses Reference - Complete status reference
- Webhook Events Reference - All webhook event types