Skip to main content

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 notificationUrl and puts the signature in the X-Papi-Signature header. 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

HeaderValue
Content-Typeapplication/json
Acceptapplication/json
User-AgentPAPI-Callback/1.0
Content-LengthSize of the body, in bytes.
X-Papi-Signaturet=<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.

  1. In the dashboard, open the page of your application.
  2. Open the Développeur tab.
  3. 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).

Keep the secret on your server

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:

PartDescription
tUnix 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.
v1The signature, in lowercase hexadecimal.

Papi computes v1 as follows:

signed_message = t + "." + raw_body
v1 = lowercase_hex( HMAC-SHA256( key = secret, message = signed_message ) )
  • key is the UTF-8 bytes of the whole secret string, pwhsec_ prefix included.
  • raw_body is the exact bytes of the request body, as sent by Papi.

Verify the signature

On every notification your endpoint receives:

  1. Read the raw request body, as bytes, before any JSON parsing.
  2. Read the X-Papi-Signature header and extract the values of t and v1. If the header is missing or malformed, reject the request.
  3. Compute the expected signature: HMAC-SHA256 of t + "." + raw_body, with your secret as the key, encoded in lowercase hexadecimal.
  4. Compare the expected signature with v1 using a constant-time comparison.
  5. Reject the request if the difference between the current time and t is greater than 300 seconds. This protects you against replayed notifications.
  6. If any check fails, respond with a non-2xx status (for example 401) and do not process the notification.
  7. If all checks pass, parse the JSON body and continue with the additional checks.

Papi treats any non-2xx response as a failed notification.

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();
});

Test your implementation

Use these values to check your code before you receive a real notification:

InputValue
Secretpwhsec_5f1c2b7e9a0d4c3b8e6f1a2d9c7b4e0f3a6d8c1b5e9f2a7d4c0b3e6f9a1d8c2b
t1757750400
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'
note

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

SymptomCause
The signature never matches, although the secret is correctThe 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 matchesThe key does not include the pwhsec_ prefix. The key is the whole secret string.
The raw body is empty or already an objectA 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 oldThe clock of your server is not synchronized. Synchronize it with NTP.
The check works but is not safeThe 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:

  • merchantPaymentReference matches the reference you sent.
  • notificationToken matches 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 2xx status 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 paymentReference or merchantPaymentReference. If the payment is already in its final state in your database, respond with a 2xx status and do not process it again.
  • Do not use the signature to detect duplicates. A resent notification has a new t value 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.