Gareth Wilson Gareth Wilson

Guide to Paysafe Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationThe Paysafe Business Portal, per account
Method and formatPOST, JSON
Signature headerSignature
SchemeHMAC-SHA256, base64, no prefix
Signed contentThe UTF-8 JSON request body
KeyThe hmacKey, used as a plain string by Paysafe's official SDKs
Replay windowNone. No timestamp is signed
Event nameeventName (and an identical eventType) in the per-method examples
RetriesUp to three, on a 4xx or 5xx response
OrderingAsynchronous. 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:

CategoryEvents
Payment handlePAYMENT_HANDLE_INITIATED, PAYMENT_HANDLE_PROCESSING, PAYMENT_HANDLE_PAYABLE, PAYMENT_HANDLE_COMPLETED, PAYMENT_HANDLE_FAILED
PaymentPAYMENT_PROCESSING, PAYMENT_RECEIVED, PAYMENT_COMPLETED, PAYMENT_HELD, PAYMENT_FAILED, PAYMENT_CANCELLED
SettlementSETTLEMENT_RECEIVED, SETTLEMENT_PENDING, SETTLEMENT_PROCESSING, SETTLEMENT_COMPLETED, SETTLEMENT_FAILED, SETTLEMENT_CANCELLED
Void authorizationVOIDAUTH_RECEIVED, VOIDAUTH_PENDING, VOIDAUTH_COMPLETED, VOIDAUTH_FAILED, VOIDAUTH_CANCELLED
RegistrationREGISTRATION_COMPLETED, REGISTRATION_FAILED, REGISTRATION_DECLINED
RefundsREFUND_PROCESSING, REFUND_RECEIVED, REFUND_PENDING, REFUND_COMPLETED, REFUND_FAILED, REFUND_CANCELLED
Original creditORIGINAL_CREDIT_PROCESSING, _PENDING, _COMPLETED, _FAILED, _CANCELLED
Standalone creditSA_CREDIT_PROCESSING, _RECEIVED, _PENDING, _HELD, _COMPLETED, _FAILED, _CANCELLED
Payment schedulerSCHEDULED_PAYMENT_COMPLETED, 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

  1. Sign in to the Paysafe Business Portal and open the webhook configuration for your account.
  2. Enter your HTTPS endpoint URL and choose the event categories or events you want.
  3. 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 Signature header, 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 200 straight away.
  • Reconcile pending payments through the Paysafe API, using merchantRefNum or 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's status and type when 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 resourceId plus eventName.
  • Make status updates idempotent: setting a payment to COMPLETED twice 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.