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 | HMAC signature for verification |
X-Paysera-Event | Event type (e.g., order.paid) |
X-Paysera-Timestamp | Unix timestamp of the event |
Body Structure​
{
"event": {
"name": "order.paid",
"type": "order",
"timestamp": 1736433570
},
"order": {
"id": "a6f2b8e3-5e5f-47d9-b13f-87ed2db2938a",
"projectId": "01990618-2c90-7103-9169-752bdaac7a52",
"status": "paid",
"amount": 2500,
"currency": "EUR",
"reference": "ORDER-12345",
"amountPaid": 2500,
"balanceDue": 0,
"createdAt": 1736433270,
"updatedAt": 1736433570
},
"paymentLink": {
"id": "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f",
"status": "completed"
}
}
Event Types​
| Event | Order Status | Description |
|---|---|---|
order.paid | paid | Order has been fully paid |
order.pending_payment | pending_payment | Order awaiting payment |
payment_link.completed | - | Payment link completed |
payment_link.expired | - | Payment link expired |
payment_link.canceled | - | Payment link canceled |
Signature Verification​
Always verify webhook signatures before processing to prevent fraud.
Verification Process​
- Get the signature from
X-Paysera-Signatureheader - Get the raw request body
- Compute HMAC-SHA256 of the body using your client secret
- Compare signatures (constant-time comparison)
- 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
{
$eventName = $data['event']['name'] ?? null;
$order = $data['order'] ?? null;
if (!$eventName || !$order) {
throw new Exception('Missing event or order data');
}
$reference = $order['reference'] ?? null;
$status = $order['status'] ?? null;
switch ($eventName) {
case 'order.paid':
$this->handleOrderPaid($reference, $order);
break;
case 'order.pending_payment':
$this->handleOrderPendingPayment($reference, $order);
break;
case 'payment_link.completed':
$this->handlePaymentLinkCompleted($reference, $data);
break;
case 'payment_link.expired':
$this->handlePaymentLinkExpired($reference, $data);
break;
default:
error_log("Unhandled event: $eventName");
}
}
private function handleOrderPaid(string $reference, array $order): void
{
// Update order status in your database
// Send confirmation email
// Fulfill the order
error_log("Order paid: $reference");
}
private function handleOrderPendingPayment(string $reference, array $order): void
{
// Order is still awaiting payment
error_log("Order pending payment: $reference");
}
private function handlePaymentLinkCompleted(string $reference, array $data): void
{
// Payment link was used successfully
error_log("Payment link completed for order: $reference");
}
private function handlePaymentLinkExpired(string $reference, array $data): void
{
// Payment link expired - may need to create a new one
error_log("Payment link expired for order: $reference");
}
}
// Usage
$handler = new PayseraWebhookHandler(getenv('PAYSERA_CLIENT_SECRET'));
$handler->handle();
Idempotency​
Webhooks may be delivered multiple times. Implement idempotent processing:
<?php
class IdempotentWebhookHandler
{
private PDO $db;
public function processEvent(array $data): void
{
$orderId = $data['order']['id'];
$eventName = $data['event']['name'];
$timestamp = $data['event']['timestamp'];
// Check if already processed
$eventKey = md5($orderId . $eventName . $timestamp);
$stmt = $this->db->prepare(
'SELECT id FROM processed_webhooks WHERE event_key = ?'
);
$stmt->execute([$eventKey]);
if ($stmt->fetch()) {
// Already processed, skip
return;
}
// Process the event
$this->doProcess($data);
// Mark as processed
$stmt = $this->db->prepare(
'INSERT INTO processed_webhooks (event_key, processed_at) VALUES (?, NOW())'
);
$stmt->execute([$eventKey]);
}
}
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