Gareth Wilson Gareth Wilson

Guide to SpherePay Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAPI: POST /v2/webhook-endpoints
Subscription modelEvent names, or *, customer.*, transfer.*
Endpoint limit6 active endpoints per application
UpdatesNone. Delete and recreate to change anything, including the secret
Signature headerSphere-Signature, hex
Timestamp headerSphere-Timestamp, Unix seconds, fresh on every attempt
Other headersSphere-Delivery-Id, Sphere-Delivery-Attempt, Sphere-Delivery-Type (original or replay), Sphere-Replay-Reason
SchemeHMAC-SHA256, hex
Signed content<Sphere-Timestamp>.<raw body>
Secretwhsec_..., returned once, used whole as the key
Response window15 seconds, any 2xx. Redirects are not followed
RetriesNone. Failed deliveries are replayed manually
OrderingNot guaranteed. Use sequence
Source IPsNot published

Common events

ResourceEventsFires when
Customerscustomer.created, customer.pending, customer.approved, customer.rejectedA customer is created, or one of its verification profiles moves to pending, approved or rejected
Transferstransfer.created, transfer.pendingFunding, transfer.pendingReview, transfer.fundsReceived, transfer.processing, transfer.succeededA transfer is created, waits for funding or review, receives funds, starts processing or succeeds
Transfer problemstransfer.returned, transfer.pendingRefundInformation, transfer.refunded, transfer.failed, transfer.canceled, transfer.expired, transfer.failedPrecondition, transfer.unexpectedErrorA 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:

{
  "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:

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.

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:

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 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 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.

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.

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 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 for free and handle SpherePay 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.