4. Securing notifications (callbacks)
This page is for merchant backend developers. It explains how Papi secures the notifications (callbacks) it sends to your notificationUrl, and how you must secure the endpoint that receives them.
Why secure the callback endpoint
Your notificationUrl is a public URL. Papi calls it to send you the final status of a payment, but anyone who knows this URL can also send a POST request to it. For example, an attacker can send a fake notification with "paymentStatus": "SUCCESS" for an order that was never paid.
If your endpoint updates the order without checking where the notification comes from, you can deliver goods or services that were never paid. Your endpoint must verify every notification before it changes any data.
How Papi protects notifications
- Every notification is signed. Papi signs each notification it sends to your
notificationUrland puts the signature in theX-Papi-Signatureheader. There is no unsigned notification. - One secret per application. The signature is computed with the signing secret of your application (shop). It is shared only between Papi, your server and the members of your organization who can open the shop in the dashboard.
- Protection against replays. The signed message includes a timestamp. Your endpoint rejects notifications that are too old, so a captured notification cannot be sent again later.
- Backward compatible. The body of the notification does not change, and it still contains the
notificationToken. Integrations that do not verify the signature keep working. However, you must verify the signature before you trust a notification.
Before you trust the body, verify the signature in the X-Papi-Signature header. Then run the additional checks on merchantPaymentReference and notificationToken.
Headers sent with a notification
| Header | Value |
|---|---|
Content-Type | application/json |
Accept | application/json |
User-Agent | PAPI-Callback/1.0 |
Content-Length | Size of the body, in bytes. |
X-Papi-Signature | t=<unix_seconds>,v1=<signature> — see below. |
Example:
X-Papi-Signature: t=1757750400,v1=66c446f11f07c733a8ded2580681d7e0430cc490637479178506fd2a66595d53
Where to find the signing secret
Each application (shop) has one signing secret. Papi creates it automatically when the application is created.
- In the dashboard, open the page of your application.
- Open the Développeur tab.
- The secret is right under the API key (Clé API), in the field Secret de signature des notifications (X-Papi-Signature).
The secret has the format pwhsec_ followed by 64 lowercase hexadecimal characters (71 characters in total).
Treat the signing secret like your API key: store it server-side only, never in frontend code, a mobile app or a URL. The secret cannot be regenerated. If it leaks, contact Papi support.
How the signature is built
The X-Papi-Signature header has two parts, separated by a comma:
| Part | Description |
|---|---|
t | Unix timestamp, in seconds, of the moment Papi signed the notification. It is part of the signed message, so it cannot be changed without breaking the signature. |
v1 | The signature, in lowercase hexadecimal. |
Papi computes v1 as follows:
signed_message = t + "." + raw_body
v1 = lowercase_hex( HMAC-SHA256( key = secret, message = signed_message ) )
keyis the UTF-8 bytes of the whole secret string,pwhsec_prefix included.raw_bodyis the exact bytes of the request body, as sent by Papi.
Verify the signature
On every notification your endpoint receives:
- Read the raw request body, as bytes, before any JSON parsing.
- Read the
X-Papi-Signatureheader and extract the values oftandv1. If the header is missing or malformed, reject the request. - Compute the expected signature: HMAC-SHA256 of
t + "." + raw_body, with your secret as the key, encoded in lowercase hexadecimal. - Compare the expected signature with
v1using a constant-time comparison. - Reject the request if the difference between the current time and
tis greater than 300 seconds. This protects you against replayed notifications. - If any check fails, respond with a non-2xx status (for example
401) and do not process the notification. - If all checks pass, parse the JSON body and continue with the additional checks.
Papi treats any non-2xx response as a failed notification.
- Node.js (Express)
- PHP
- Python (Flask)
- Java (Spring)
const express = require('express');
const crypto = require('crypto');
const PAPI_WEBHOOK_SECRET = process.env.PAPI_WEBHOOK_SECRET; // pwhsec_...
const TOLERANCE_SECONDS = 300;
function verifyPapiSignature(rawBody, header, secret, toleranceSeconds = TOLERANCE_SECONDS) {
if (!Buffer.isBuffer(rawBody) || !header || !secret) return false;
const parts = {};
for (const item of header.split(',')) {
const [key, ...rest] = item.trim().split('=');
parts[key] = rest.join('=');
}
const { t, v1 } = parts;
if (!/^\d+$/.test(t || '') || !/^[0-9a-f]{64}$/.test(v1 || '')) return false;
const now = Math.floor(Date.now() / 1000);
if (toleranceSeconds > 0 && Math.abs(now - Number(t)) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return crypto.timingSafeEqual(expected, Buffer.from(v1, 'hex'));
}
const app = express();
// express.raw keeps the body as a Buffer: do not use express.json() on this route
app.post('/payment-notify', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyPapiSignature(req.body, req.get('X-Papi-Signature'), PAPI_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const notification = JSON.parse(req.body.toString('utf8'));
// Additional checks: merchantPaymentReference and notificationToken
// Update your order from notification.paymentStatus
res.status(200).end();
});
<?php
const TOLERANCE_SECONDS = 300;
function verifyPapiSignature(string $rawBody, ?string $header, string $secret, int $toleranceSeconds = TOLERANCE_SECONDS): bool
{
if ($header === null || $header === '' || $secret === '') {
return false;
}
$parts = [];
foreach (explode(',', $header) as $item) {
$pair = explode('=', trim($item), 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if (!ctype_digit($t) || !preg_match('/\A[0-9a-f]{64}\z/', $v1)) {
return false;
}
if ($toleranceSeconds > 0 && abs(time() - (int) $t) > $toleranceSeconds) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $v1);
}
// payment-notify.php (or the path matching your notificationUrl)
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_PAPI_SIGNATURE'] ?? null;
$secret = getenv('PAPI_WEBHOOK_SECRET') ?: ''; // pwhsec_...
if ($rawBody === false || !verifyPapiSignature($rawBody, $header, $secret)) {
http_response_code(401);
exit();
}
$data = json_decode($rawBody, true);
// Additional checks: merchantPaymentReference and notificationToken
// Update your order from $data['paymentStatus']
http_response_code(200);
import hashlib
import hmac
import json
import os
import re
import time
from flask import Flask, abort, request
app = Flask(__name__)
PAPI_WEBHOOK_SECRET = os.environ["PAPI_WEBHOOK_SECRET"] # pwhsec_...
TOLERANCE_SECONDS = 300
def verify_papi_signature(raw_body, header, secret, tolerance_seconds=TOLERANCE_SECONDS):
if not header or not secret:
return False
parts = dict(item.strip().split("=", 1) for item in header.split(",") if "=" in item)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not re.fullmatch(r"[0-9]+", t) or not re.fullmatch(r"[0-9a-f]{64}", v1):
return False
if tolerance_seconds > 0 and abs(int(time.time()) - int(t)) > tolerance_seconds:
return False
expected = hmac.new(secret.encode("utf-8"), t.encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/payment-notify")
def payment_notify():
raw_body = request.get_data() # raw bytes, before any JSON parsing
if not verify_papi_signature(raw_body, request.headers.get("X-Papi-Signature"), PAPI_WEBHOOK_SECRET):
abort(401)
notification = json.loads(raw_body)
# Additional checks: merchantPaymentReference and notificationToken
# Update your order from notification["paymentStatus"]
return "", 200
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class NotificationController {
private static final long TOLERANCE_SECONDS = 300;
@Value("${papi.webhook-secret}")
private String webhookSecret; // pwhsec_...
@PostMapping("/payment-notify")
public ResponseEntity<Void> handleNotification(@RequestBody byte[] body, @RequestHeader(value = "X-Papi-Signature", required = false) String signature) throws Exception {
if (!verifyPapiSignature(body, signature, webhookSecret, TOLERANCE_SECONDS)) {
return ResponseEntity.status(401).build();
}
// Parse the JSON only now, for example: objectMapper.readValue(body, Map.class)
// Additional checks: merchantPaymentReference and notificationToken
return ResponseEntity.ok().build();
}
static boolean verifyPapiSignature(byte[] rawBody, String header, String secret, long toleranceSeconds) throws Exception {
if (header == null || secret == null || secret.isEmpty()) {
return false;
}
Map<String, String> parts = new HashMap<>();
for (String item : header.split(",")) {
String[] pair = item.trim().split("=", 2);
if (pair.length == 2) {
parts.put(pair[0], pair[1]);
}
}
String t = parts.getOrDefault("t", "");
String v1 = parts.getOrDefault("v1", "");
if (!t.matches("[0-9]{1,18}") || !v1.matches("[0-9a-f]{64}")) {
return false;
}
long now = System.currentTimeMillis() / 1000;
if (toleranceSeconds > 0 && Math.abs(now - Long.parseLong(t)) > toleranceSeconds) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = mac.doFinal(rawBody);
return MessageDigest.isEqual(expected, HexFormat.of().parseHex(v1));
}
}
HexFormat requires Java 17 or later.
Test your implementation
Use these values to check your code before you receive a real notification:
| Input | Value |
|---|---|
| Secret | pwhsec_5f1c2b7e9a0d4c3b8e6f1a2d9c7b4e0f3a6d8c1b5e9f2a7d4c0b3e6f9a1d8c2b |
t | 1757750400 |
| Raw body | {"paymentReference":"PAPI-TEST-0001","paymentStatus":"SUCCESS","amount":150000} |
Expected header:
X-Papi-Signature: t=1757750400,v1=66c446f11f07c733a8ded2580681d7e0430cc490637479178506fd2a66595d53
You can compute the same signature with openssl. The printed digest must be equal to the v1 value:
printf '%s' '1757750400.{"paymentReference":"PAPI-TEST-0001","paymentStatus":"SUCCESS","amount":150000}' \
| openssl dgst -sha256 -hmac 'pwhsec_5f1c2b7e9a0d4c3b8e6f1a2d9c7b4e0f3a6d8c1b5e9f2a7d4c0b3e6f9a1d8c2b'
The t of this test vector is in the past. When you test your code with it, disable the 300-second check (in the examples above, pass a tolerance of 0). Keep the check enabled in production.
Common pitfalls
| Symptom | Cause |
|---|---|
| The signature never matches, although the secret is correct | The JSON body was parsed and serialized again before computing the HMAC. Key order, whitespace or character escaping change, so the bytes are different. Always use the raw body. |
| The signature never matches | The key does not include the pwhsec_ prefix. The key is the whole secret string. |
| The raw body is empty or already an object | A framework middleware (for example express.json() or a request-logging filter) read and parsed the body before your handler. Read the raw body on the notification route. |
| Valid notifications are rejected as too old | The clock of your server is not synchronized. Synchronize it with NTP. |
| The check works but is not safe | The signatures are compared with == or ===. Use a constant-time comparison (crypto.timingSafeEqual, hash_equals, hmac.compare_digest, MessageDigest.isEqual). |
Additional checks
After the signature is valid, you can also verify that:
merchantPaymentReferencematches the reference you sent.notificationTokenmatches the one you received in the payment‑link creation response.
If the signature and both checks pass, the notification is authentic and you can safely update your database.
Securing the endpoint itself
A valid signature proves that a notification comes from Papi. Also apply these practices to the endpoint that receives the notifications.
Use HTTPS
Serve your notification endpoint over HTTPS in production.
Keep secrets out of the URL
Never put your API key or your signing secret in the notificationUrl, for example in a query string. URLs are written to the logs of servers, proxies and tunnels.
Respond quickly
- Respond with a
2xxstatus as soon as the notification is verified and stored. Do heavy processing (emails, stock updates, calls to other systems) after you respond. - Papi waits at most 10 seconds to connect to your endpoint and at most 30 seconds for the response.
- Papi treats any non-2xx response, and any timeout, as a failed notification.
Handle duplicate notifications
Your endpoint can receive the notification of the same payment more than once, for example when the notification is resent. Process notifications idempotently:
- Identify the payment with
paymentReferenceormerchantPaymentReference. If the payment is already in its final state in your database, respond with a2xxstatus and do not process it again. - Do not use the signature to detect duplicates. A resent notification has a new
tvalue and a new signature.
Failed notifications
Papi sends a notification once, automatically. If it failed, you can:
- Resend it from the Papi dashboard: open the payment details, open the Développeur tab, then click Renvoyer le callback.
- Read the outcome of the payment back with
GET /engine/api/payment-links/{merchantPaymentReference}. See Reading a payment link back.
Allow the Papi user agent
Some firewalls and web application firewalls (WAF) block requests from unknown user agents. Allow requests with the header User-Agent: PAPI-Callback/1.0 on your notification endpoint.
Local development
Papi cannot reach localhost from its servers. To receive notifications on your local machine, use a tunnel: see the step Create the notification endpoint (Callback URL) in the integration guide.