# 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_HANDLE_PROCESSING`, `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_CREDIT_PROCESSING`, `_PENDING`, `_COMPLETED`, `_FAILED`, `_CANCELLED` |
| Standalone credit | `SA_CREDIT_PROCESSING`, `_RECEIVED`, `_PENDING`, `_HELD`, `_COMPLETED`, `_FAILED`, `_CANCELLED` |
| Payment scheduler | `SCHEDULED_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:

```json
{
  "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.

```javascript
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:

```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](/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-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](https://hookdeck.com) sits in front of your receiver, checks every Paysafe signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Paysafe webhooks reliably in minutes.