Guide to Paysafe Webhooks: Features and Best Practices
Paysafe webhooks tell your systems how payments are progressing: a payment handle becomes payable, a payment completes, is held for risk review or fails, a settlement batches, a refund completes, or a scheduled payment is retried. For redirect-flow payment methods such as Skrill, Paysafecard, PayPal and Interac, where the customer finishes paying somewhere else, webhooks are how you find out what happened.
This guide covers webhooks for Paysafe's Payments API and Paysafe Checkout: how they are configured and signed, the events and the payload envelope, the gaps in Paysafe's documentation, and the best practices for running a Paysafe receiver in production.
What are Paysafe webhooks?
Paysafe webhooks are configured per account in the Paysafe Business Portal. When a subscribed event happens, Paysafe sends a JSON POST to your URL, asynchronously. Paysafe describes the payloads as minimal: they identify the event and the resource and link to it in the API, so you can fetch the full details.
Every request carries a Signature header: the base64-encoded HMAC-SHA256 of the request body, keyed with your HMAC key. Your receiver repeats the calculation and compares the result.
Paysafe webhook features
| Feature | Details |
|---|---|
| Configuration | The Paysafe Business Portal, per account |
| Method and format | POST, JSON |
| Signature header | Signature |
| Scheme | HMAC-SHA256, base64, no prefix |
| Signed content | The UTF-8 JSON request body |
| Key | The hmacKey, used as a plain string by Paysafe's official SDKs |
| Replay window | None. No timestamp is signed |
| Event name | eventName (and an identical eventType) in the per-method examples |
| Retries | Up to three, on a 4xx or 5xx response |
| Ordering | Asynchronous. No ordering guarantee is documented |
Paysafe doesn't document the response timeout, the interval between retries, source IP addresses or an event ID.
Common events
Paysafe groups events into nine categories:
| Category | Events |
|---|---|
| Payment handle | PAYMENT_HANDLE_INITIATED, PAYMENT_, PAYMENT_HANDLE_PAYABLE, PAYMENT_HANDLE_COMPLETED, PAYMENT_HANDLE_FAILED |
| Payment | PAYMENT_PROCESSING, PAYMENT_RECEIVED, PAYMENT_COMPLETED, PAYMENT_HELD, PAYMENT_FAILED, PAYMENT_CANCELLED |
| Settlement | SETTLEMENT_RECEIVED, SETTLEMENT_PENDING, SETTLEMENT_PROCESSING, SETTLEMENT_COMPLETED, SETTLEMENT_FAILED, SETTLEMENT_CANCELLED |
| Void authorization | VOIDAUTH_RECEIVED, VOIDAUTH_PENDING, VOIDAUTH_COMPLETED, VOIDAUTH_FAILED, VOIDAUTH_CANCELLED |
| Registration | REGISTRATION_COMPLETED, REGISTRATION_FAILED, REGISTRATION_DECLINED |
| Refunds | REFUND_PROCESSING, REFUND_RECEIVED, REFUND_PENDING, REFUND_COMPLETED, REFUND_FAILED, REFUND_CANCELLED |
| Original credit | ORIGINAL_, _PENDING, _COMPLETED, _FAILED, _CANCELLED |
| Standalone credit | SA_CREDIT_PROCESSING, _RECEIVED, _PENDING, _HELD, _COMPLETED, _FAILED, _CANCELLED |
| Payment scheduler | SCHEDULED_, SCHEDULED_PAYMENT_FAILED, RETRY_COMPLETED, RETRY_FAILED, SUBSCRIPTION_SUSPENDED, SUBSCRIPTION_CANCELLED, TRIAL_PAYMENT_COMPLETED, TRIAL_PAYMENT_FAILED |
Paysafe's per-method pages also show PAYMENT_HANDLE_EXPIRED, PAYMENT_HANDLE_ERRORED, SA_CREDIT_ERRORED and REFUND_ERRORED, which aren't in the event tables. Expect other _EXPIRED and _ERRORED values too.
Here is the PAYMENT_COMPLETED example from Paysafe's EPS webhooks page, trimmed:
{
"eventName": "PAYMENT_COMPLETED",
"eventType": "PAYMENT_COMPLETED",
"attemptNumber": "1",
"resourceId": "26eb2b3b-05ca-461c-8f05-abbe41935978",
"eventDate": "2025-03-24T15:31:11Z",
"mode": "live",
"links": [
{
"href": "https://api.test.paysafe.com/alternatepayments/v1/accounts/1002786770/payments/26eb2b3b-05ca-461c-8f05-abbe41935978",
"rel": "payment"
}
],
"payload": {
"accountId": "1002786770",
"id": "26eb2b3b-05ca-461c-8f05-abbe41935978",
"merchantRefNum": "854ff860-4409-42e3-bea7-aec9a8bcab97",
"paymentHandleToken": "PHPTRF98fcZLo6ve",
"amount": 300,
"currencyCode": "EUR",
"status": "COMPLETED",
"paymentType": "EPS",
"txnTime": "2025-03-24T15:31:11Z"
}
}
payload is the resource as Paysafe's GET call would return it: a payment handle, payment, refund or credit. links[].rel names the resource type, and resourceId is its ID. amount is in minor units (300 is EUR 3.00). attemptNumber is a string that counts delivery attempts, so it changes on a retry.
The general configure-webhooks page shows a different example: a bare payment handle object with no eventName, eventType or payload wrapper. Handle both shapes until you've seen what your account receives.
Setting up Paysafe webhooks
- Sign in to the Paysafe Business Portal and open the webhook configuration for your account.
- Enter your HTTPS endpoint URL and choose the event categories or events you want.
- Store the HMAC key server-side, for example as
PAYSAFE_WEBHOOK_SECRET.
Paysafe's webhook page calls the key hmacKey but doesn't say where it is issued. Webhooks are configured in the Business Portal, so start there, and ask your Paysafe contact if you can't find it.
If you use Hookdeck, create a Paysafe source, set the HMAC key on it, and use the Hookdeck source URL as the webhook URL.
Securing Paysafe webhooks
Compute an HMAC-SHA256 of the raw request body with the HMAC key, encode the digest as base64, and compare it with the Signature header in constant time. Base64 is case-sensitive, so compare it exactly.
Use the key as a plain UTF-8 string. That is what Paysafe's official Java and PHP Payments API SDKs do, and it's what Hookdeck's Paysafe source does. Paysafe's older Alternate Payments v1 samples base64-decode the key first. If a correct-looking verifier never matches, that difference is the first thing to check.
const crypto = require('crypto');
const express = require('express');
function verifyPaysafeSignature(rawBody, signatureHeader, hmacKey) {
if (!signatureHeader) return false;
const expected = crypto.createHmac('sha256', hmacKey).update(rawBody).digest('base64');
const received = Buffer.from(signatureHeader.trim(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/paysafe', express.raw({ type: '*/*' }), (req, res) => {
const hmacKey = process.env.PAYSAFE_WEBHOOK_SECRET;
if (!hmacKey) return res.sendStatus(500); // fail closed
if (!verifyPaysafeSignature(req.body, req.get('signature'), hmacKey)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
const eventName = event.eventName ?? event.eventType; // absent on the bare-resource shape
// Persist the event, then process it asynchronously.
res.sendStatus(200);
});
The same check in Python:
import base64
import hashlib
import hmac
def verify_paysafe_signature(raw_body: bytes, signature_header, hmac_key: str) -> bool:
if not signature_header:
return False
digest = hmac.new(hmac_key.encode("utf-8"), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest)
return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)
# In FastAPI: raw_body = await request.body()
# verify_paysafe_signature(raw_body, request.headers.get("signature"),
# os.environ["PAYSAFE_WEBHOOK_SECRET"])
To check your verifier, use the key paysafe-test-key and this exact body:
{"eventName":"PAYMENT_HANDLE_COMPLETED","eventType":"PAYMENT_HANDLE_COMPLETED","attemptNumber":"1","resourceId":"99d13831-253d-42fd-751d-45fdaf8f4239","payload":{"id":"99d13831-253d-42fd-751d-45fdaf8f4239","status":"COMPLETED"}}
The Signature header should be:
tuBfRU/Aa7Jt7+tf3zx8MsCedSXBKTdiRtaFygEGyD0=
Make Paysafe webhooks production-ready. Hookdeck Event Gateway verifies the
Signatureheader, accepts every payment, settlement and refund event as soon as it arrives, and durably queues it, so Paysafe's three retries are never your only chance to process an event.
Paysafe webhook limitations and pain points
Only three retries
The Problem: A short outage can lose payment updates, and for redirect-flow methods the webhook may be your only notice that a customer paid.
Why It Happens: Paysafe retries a 4xx or 5xx response up to three times. It doesn't document how far apart the retries are, so you can't tell how long an outage it covers.
Workarounds:
- Keep the receiver's job to verification and storage, and return
200straight away. - Reconcile pending payments through the Paysafe API, using
merchantRefNumor the resource ID, for anything that hasn't reached a final status.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives and stores it, then retries delivery to your service on a schedule you control and lets you replay any event, so your downtime doesn't have to fit inside Paysafe's retries.
Two payload shapes
The Problem: A handler that reads event.eventName gets undefined, or one that reads event.status finds it under payload.
Why It Happens: The per-method webhook pages show an envelope with eventName, eventType and payload. The main configure-webhooks page shows a bare resource with no event name at all.
Workarounds:
- Read
eventName ?? eventType, and fall back to the resource'sstatusandtypewhen neither is present. - Log the first deliveries your account receives and settle on the shape you see.
How Hookdeck Can Help: Hookdeck transformations can normalise both shapes into one before the event reaches your service.
The key's source and format aren't documented
The Problem: Verification fails, and the docs don't say where the key comes from or how to use it.
Why It Happens: The webhook page names hmacKey in a formula and nothing more. The current SDKs use it as a string, while older Paysafe API samples decode it from base64 first.
Workarounds:
- Use the key as a UTF-8 string, as above.
- Test your verifier against the vector above, then against one real delivery.
How Hookdeck Can Help: Hookdeck's Paysafe source verifies the signature with the key as a string and records each request's verification result, so you can see whether real deliveries pass before writing your own code.
No event ID
The Problem: You can't tell a retry from a new event by an ID, and the body changes between attempts.
Why It Happens: There's no event ID, and attemptNumber increases on each retry, so a hash of the whole body differs too.
Workarounds:
- Deduplicate on
resourceIdpluseventName. - Make status updates idempotent: setting a payment to
COMPLETEDtwice should do nothing the second time.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries based on resourceId and eventName while ignoring attemptNumber.
Best practices
Verify against the raw body
The signature covers the exact bytes Paysafe sent. In Express, mount express.raw() on the webhook route and parse after verification. In a Next.js route handler, use Buffer.from(await request.arrayBuffer()). In FastAPI, read await request.body().
Fetch before you fulfil
Paysafe describes webhook payloads as minimal and gives you links to the resource. Before you ship goods or credit an account, fetch the payment from the API and check its status and amount.
Don't assume order
Events are sent asynchronously and retried independently, so PAYMENT_HANDLE_COMPLETED can arrive after PAYMENT_COMPLETED. Only move a resource forward in its lifecycle, never backwards.
Watch mode
Paysafe's examples include mode. Make sure test and live events go to separate endpoints or are kept apart in your data.
Acknowledge fast and process asynchronously
See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
Paysafe signs each webhook with a base64 HMAC-SHA256 of the raw body in the Signature header, keyed with your HMAC key used as a string. Verify against the raw bytes, handle both the enveloped and bare payload shapes, deduplicate on resourceId plus eventName, fetch the resource before you fulfil, and acknowledge quickly, because Paysafe retries only three times.
Hookdeck Event Gateway sits in front of your receiver, checks every Paysafe signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Paysafe webhooks reliably in minutes.