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 with exponential backoff.
| Attempt | Delay |
|---|---|
| 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​
| 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. 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.
| Event | Type | Description |
|---|---|---|
order.created | order | Order was created |
order.status_updated | order | Order status changed (e.g., to paid) |
order.reference_updated | order | Order reference was changed |
order.amount_updated | order | Order total amount was changed |
order.amount_paid_updated | order | Order's amount paid was updated |
order.payment_link.expired_at_updated | order | A payment link's expiration time changed |
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​
- 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, project_webhook_secret)and hex-encode it. - Compare with a constant-time comparison.
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
- 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_PROJECT_WEBHOOK_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_PROJECT_WEBHOOK_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_PROJECT_WEBHOOK_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_PROJECT_WEBHOOK_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_PROJECT_WEBHOOK_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_PROJECT_WEBHOOK_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
{
$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:
- 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