# Guide to SpherePay Webhooks: Features and Best Practices

SpherePay webhooks tell your application when money and customers move through their lifecycle: a customer's verification is approved, a transfer receives funds, a payout succeeds, or a transfer is returned or refunded. If you use SpherePay to move between stablecoins and bank accounts, webhooks are the way SpherePay recommends tracking transfer progress, instead of polling its `GET` endpoints.

This guide covers how SpherePay webhook endpoints are registered and signed, the customer and transfer events, SpherePay's single-attempt delivery model, and the practices that keep a SpherePay receiver reliable.

## What are SpherePay webhooks?

A SpherePay webhook endpoint is a URL plus the events it subscribes to, registered through the API for one SpherePay application. When a subscribed event occurs, SpherePay sends a signed JSON `POST` describing the state change: the resource's ID, its previous status and its new status. Payloads don't carry the full resource, so you fetch it from the API when you need more.

Each delivery is signed with the endpoint's secret. `Sphere-Signature` is a hex HMAC-SHA256 of the `Sphere-Timestamp` header, a dot and the raw body. The secret starts with `whsec_`, but this is not Standard Webhooks: the whole string, prefix included, is the HMAC key, and it isn't base64-decoded.

The most important thing to know is that SpherePay makes exactly one delivery attempt per event per endpoint. If your endpoint is down, slow or returns an error, the delivery is marked failed and stays that way until you replay it.

## SpherePay webhook features

| Feature | Details |
| --- | --- |
| Configuration | API: `POST /v2/webhook-endpoints` |
| Subscription model | Event names, or `*`, `customer.*`, `transfer.*` |
| Endpoint limit | 6 active endpoints per application |
| Updates | None. Delete and recreate to change anything, including the secret |
| Signature header | `Sphere-Signature`, hex |
| Timestamp header | `Sphere-Timestamp`, Unix seconds, fresh on every attempt |
| Other headers | `Sphere-Delivery-Id`, `Sphere-Delivery-Attempt`, `Sphere-Delivery-Type` (`original` or `replay`), `Sphere-Replay-Reason` |
| Scheme | HMAC-SHA256, hex |
| Signed content | `<Sphere-Timestamp>.<raw body>` |
| Secret | `whsec_...`, returned once, used whole as the key |
| Response window | 15 seconds, any 2xx. Redirects are not followed |
| Retries | None. Failed deliveries are replayed manually |
| Ordering | Not guaranteed. Use `sequence` |
| Source IPs | Not published |

## Common events

| Resource | Events | Fires when |
| --- | --- | --- |
| Customers | `customer.created`, `customer.pending`, `customer.approved`, `customer.rejected` | A customer is created, or one of its verification profiles moves to pending, approved or rejected |
| Transfers | `transfer.created`, `transfer.pendingFunding`, `transfer.pendingReview`, `transfer.fundsReceived`, `transfer.processing`, `transfer.succeeded` | A transfer is created, waits for funding or review, receives funds, starts processing or succeeds |
| Transfer problems | `transfer.returned`, `transfer.pendingRefundInformation`, `transfer.refunded`, `transfer.failed`, `transfer.canceled`, `transfer.expired`, `transfer.failedPrecondition`, `transfer.unexpectedError` | A transfer is returned, needs refund details, is refunded, fails, is cancelled, expires or hits an error |

Customer events fire once per verification profile per transition, so a customer with several profiles produces a separate stream for each. Every event shares one envelope. Here is SpherePay's `transfer.succeeded` example:

```json
{
  "id": "event_01HXPA3M9R7DEF456",
  "type": "transfer.succeeded",
  "apiVersion": "v2",
  "originalCreateDate": "2026-08-07T14:05:30.000Z",
  "livemode": true,
  "sequence": 5,
  "data": {
    "id": "payout_d243ab2b1de4447d8a046d87fefe58cf",
    "type": "transfer",
    "applicationId": "application_1324354657",
    "customerId": "customer_f31121c389624d3697cbf3ea8830b7a4",
    "transferType": "oneTimeTransfer",
    "previousStatus": "processing",
    "previousStatusAt": "2026-08-07T14:02:15.000Z",
    "status": "succeeded",
    "statusAt": "2026-08-07T14:05:30.000Z"
  }
}

```

`id` is stable across every delivery and replay of the event. `sequence` increases per resource, keyed by `data.id`. `apiVersion` in the body is the event schema version, which is different from the dated `apiVersion` you set on the endpoint. Transfer events use the resource name `transfer`, but `data.id` has a `payout_` prefix.

## Setting up SpherePay webhooks

Register an endpoint with your API key:

```bash
curl -X POST https://api.spherepay.co/v2/webhook-endpoints \
  -H "Authorization: Bearer $SPHERE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/spherepay",
    "subscribedEvents": ["customer.approved", "customer.rejected", "transfer.*"],
    "apiVersion": "2026-08-01"
  }'

```

The URL must be HTTPS in live mode. `apiVersion` is a `YYYY-MM-DD` date that can't be in the future. The response includes `secret`, starting `whsec_`. SpherePay returns it exactly once and never in `GET` responses, so store it straight away, for example as `SPHERE_WEBHOOK_SECRET`. New endpoints are enabled immediately.

There is no update call. To change the URL or events, or to rotate the secret, create the new endpoint first, confirm it receives and verifies deliveries, then delete the old one with `DELETE /v2/webhook-endpoints/{id}`. Both receive events in between, so deduplicate. Deletion is permanent and invalidates the secret.

If you receive SpherePay webhooks through Hookdeck, create a source with the SpherePay type and use its URL as the endpoint `url`. Paste the whole secret into the source, including the `whsec_` prefix, because SpherePay uses it as a plain HMAC key. The source accepts `POST` only, which is the method SpherePay uses.

## Securing SpherePay webhooks

Read `Sphere-Timestamp` and `Sphere-Signature`. Build the signed bytes from the timestamp exactly as received, a literal `.`, and the raw request body. Compute an HMAC-SHA256 of them with the whole `whsec_...` secret as a UTF-8 key, hex-encode it, and compare it with the header in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifySphereSignature(rawBody, signatureHeader, timestampHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !timestampHeader || !secret) return false;

  const signature = signatureHeader.trim();
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false; // check before Buffer.from(x, 'hex')

  // Optional replay window, off by default. Hookdeck retries resend the original timestamp.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(timestampHeader)) return false;
    if (Math.abs(Date.now() / 1000 - Number(timestampHeader)) > toleranceSeconds) return false;
  }

  // The key is the WHOLE secret, whsec_ prefix included. Not Standard Webhooks.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(`${timestampHeader}.`, 'utf8'), rawBody]))
    .digest();
  const received = Buffer.from(signature, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/spherepay', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.SPHERE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const valid = verifySphereSignature(
    req.body,
    req.get('sphere-signature'),
    req.get('sphere-timestamp'),
    secret
  );
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Store the event, return 2xx within 15 seconds, then apply it if event.sequence is newer.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re
import time

def verify_sphere_signature(raw_body: bytes, signature_header, timestamp_header, secret: str,
                            tolerance_seconds: int = 0) -> bool:
    if not signature_header or not timestamp_header or not secret:
        return False
    signature = signature_header.strip()
    if not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
        return False
    # Optional replay window, off by default.
    if tolerance_seconds > 0:
        if not (timestamp_header.isascii() and timestamp_header.isdigit()):
            return False
        if abs(time.time() - int(timestamp_header)) > tolerance_seconds:
            return False
    signed = timestamp_header.encode("utf-8") + b"." + raw_body
    # The whole whsec_ string is the key.
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_sphere_signature(raw_body, request.headers.get("sphere-signature"),
#                         request.headers.get("sphere-timestamp"),
#                         os.environ["SPHERE_WEBHOOK_SECRET"])

```

To check your verifier, use SpherePay's test vector. The secret is `whsec_9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c`, `Sphere-Timestamp` is `1786111531`, and the raw body is exactly this single line of 495 bytes, with no trailing newline:

```
{"id":"event_01HXPA3M9R7DEF456","type":"transfer.succeeded","apiVersion":"v2","originalCreateDate":"2026-08-07T14:05:30.000Z","livemode":true,"sequence":5,"data":{"id":"payout_d243ab2b1de4447d8a046d87fefe58cf","type":"transfer","applicationId":"application_1324354657","customerId":"customer_f31121c389624d3697cbf3ea8830b7a4","transferType":"oneTimeTransfer","previousStatus":"processing","previousStatusAt":"2026-08-07T14:02:15.000Z","status":"succeeded","statusAt":"2026-08-07T14:05:30.000Z"}}

```

The signature should be:

```
4f04e86648d8f1341d431b4cfa00eaeb7c69b7b3fcee7dff97b24f95482a6be2

```

SpherePay tells you to reject deliveries whose `Sphere-Timestamp` is more than 5 minutes old, and its reference code does so. SpherePay's own manual replays get a fresh timestamp and signature, so they pass. Hookdeck's don't: Hookdeck applies no timestamp tolerance, and its retries and replays resend SpherePay's original `Sphere-Timestamp`, so a 5-minute window in your own code would reject a Hookdeck retry that arrives later. The samples above leave the check off unless you pass a tolerance.

Don't verify with a Standard Webhooks or Svix library because of the `whsec_` prefix. Those libraries strip the prefix, base64-decode the rest and expect different headers, so every delivery fails.

> **Make SpherePay webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `Sphere-Signature` header with your whole `whsec_` secret, stores every customer and transfer event, and retries delivery to your service on a schedule you control, which SpherePay itself doesn't do.

## SpherePay webhook limitations and pain points

### One attempt per event

**The Problem:** A deploy, a timeout or a `500` and the transfer update never arrives.

**Why It Happens:** SpherePay makes exactly one delivery attempt per event per endpoint. A non-2xx response, a redirect, or no response within 15 seconds marks the delivery failed, with no automatic retry. The endpoint stays enabled, so nothing stops or alerts.

**Workarounds:**

* Poll `GET /v2/events?status=failed` and replay each failure with `POST /v2/events/replay/{eventDeliveryId}`, using the delivery ID, not the event ID.
* Acknowledge within the 15 seconds and do the work afterwards.
* For transfers that matter, fetch the current state from the API before acting.

**How Hookdeck Can Help:** Hookdeck answers SpherePay with a `200` as soon as it has stored the request, so SpherePay's single attempt succeeds even when your service is down. Hookdeck then delivers to your service with [retries](/docs/retries) you configure, and you can replay any event. A request that fails signature verification is recorded as failed verification and doesn't reach your destination.

### Events arrive out of order

**The Problem:** A `transfer.processing` update overwrites a `transfer.succeeded` that arrived first.

**Why It Happens:** SpherePay doesn't guarantee ordering, and replays deliver old events later.

**Workarounds:**

* Store the highest `sequence` you have applied per `data.id`, and skip any event whose `sequence` is less than or equal to it.
* Never order by `Sphere-Timestamp`, which is the time of the attempt, not the change.

**How Hookdeck Can Help:** Hookdeck keeps every event with its body, so when a replay or an out-of-order delivery causes confusion, you can see exactly which `sequence` values arrived and when.

### No updates, and the secret is shown once

**The Problem:** Changing an endpoint's events or URL, or rotating its secret, means a new endpoint and a new secret.

**Why It Happens:** SpherePay has no update operation yet, and the secret is only in the creation response.

**Workarounds:**

* Create the replacement first and delete the old endpoint after it verifies, deduplicating in between.
* Keep the 6-endpoint limit in mind when running both side by side.

**How Hookdeck Can Help:** Point SpherePay at a Hookdeck source. Moving your own service is then a change to the Hookdeck destination, and the SpherePay endpoint and its secret stay as they are.

### A `whsec_` secret that isn't Standard Webhooks

**The Problem:** Verification with a Standard Webhooks library fails every time.

**Why It Happens:** The prefix suggests Standard Webhooks, but SpherePay uses the whole string as a plain HMAC key, signs `timestamp.body` and sends hex, not base64.

**Workarounds:**

* Use plain HMAC code like the samples above.
* Test against SpherePay's published vector.

**How Hookdeck Can Help:** Hookdeck's SpherePay source uses the whole `whsec_` string as the key, so you paste it as-is.

## Best practices

### Verify against the raw body

The signature covers the exact bytes SpherePay sent. In Express, use `express.raw({ type: '*/*' })`. In Next.js, use `Buffer.from(await request.arrayBuffer())`. In FastAPI, use `await request.body()`.

### Deduplicate on `id`, order on `sequence`

The event `id` is stable across deliveries and replays, so store it to skip duplicates. Apply a status change only if its `sequence` is higher than the last one you applied for that resource. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Route by `applicationId` when sharing a URL

If several SpherePay applications point at one receiver, each has its own endpoint secret. Use `data.applicationId` to choose the right context, and remember the secret has to be chosen before you can trust the body.

### Acknowledge fast and process asynchronously

SpherePay gives you 15 seconds and one attempt. Verify, persist, respond, then work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

SpherePay signs every delivery with a hex HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the whole `whsec_` secret. Test against SpherePay's vector, deduplicate on `id`, order on `sequence`, and plan for the fact that SpherePay tries each delivery only once.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every SpherePay signature, and holds each event until your service has processed it, so a single missed attempt doesn't become a missed transfer update.

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