# SpherePay Webhooks

## When to Use This Skill

* How do I receive SpherePay webhooks?
* How do I verify the `Sphere-Signature` header?
* Why does my SpherePay webhook signature verification fail?
* How do I handle `customer.approved` or `transfer.succeeded` events?
* How do I handle SpherePay webhooks when there are no automatic retries?

## Essential Code (USE THIS)

SpherePay (v2 webhooks) sends a bare lowercase **hex** HMAC-SHA256 digest in `Sphere-Signature`. The signed message is the `Sphere-Timestamp` header value, a literal `.`, then the **raw request body bytes**. The key is the endpoint's signing secret (`whsec_...`) used **whole** as a plain UTF-8 string, including the `whsec_` prefix.

> This is **not** Standard Webhooks: nothing is base64-decoded and the digest is hex. Do not use the `standardwebhooks` or `svix` libraries.

Paste the whole secret, including the whsec_ prefix. SpherePay uses it as a plain HMAC key.

### Verification (Node.js)

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

// rawBody: Buffer of the exact request bytes (express.raw, not express.json)
function verifySpherePay(rawBody, headers, secret) {
  const signature = headers['sphere-signature'];
  const timestamp = headers['sphere-timestamp'];
  if (!secret || !signature || !timestamp) return false;
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false; // validate before decoding

  // No timestamp window by default (see "Timestamp window" below)
  const signed = Buffer.concat([Buffer.from(timestamp, 'latin1'), Buffer.from('.'), rawBody]);
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(signed).digest();
  const received = Buffer.from(signature.toLowerCase(), 'hex');
  return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}

```

### Verification (Python)

```python
import hashlib, hmac, re

def verify_spherepay(raw_body: bytes, headers, secret: str) -> bool:
    signature = headers.get("sphere-signature")
    timestamp = headers.get("sphere-timestamp")
    if not secret or not signature or not timestamp:
        return False
    if not re.fullmatch(r"[0-9a-fA-F]{64}", signature):  # validate before decoding
        return False
    signed = b"".join([timestamp.encode("latin-1"), b".", raw_body])
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    return hmac.compare_digest(expected, bytes.fromhex(signature.lower()))

```

Respond **401** when a header is missing or empty, the signature is malformed, or it does not match. Parse the JSON only after the check passes.

**Timestamp window:** SpherePay's docs suggest rejecting a `Sphere-Timestamp` older than 5 minutes. The examples do **not** by default, because Hookdeck retries and replays resend the original headers and timestamp, so a fixed window would reject legitimate redeliveries. To opt in, set `SPHEREPAY_WEBHOOK_TOLERANCE_SECONDS` to a positive number of seconds (for example `300`); unset or `0` is off. `Sphere-Timestamp` is Unix seconds.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/spherepay-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/spherepay-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/spherepay-webhooks/examples/fastapi/).

## Delivery Behaviour

* One `POST` per event per endpoint; any **2xx within 15 seconds** acknowledges it.
* **No automatic retries.** A non-2xx or timeout marks the delivery `failed`; it can only be replayed manually through the Events API. Failures never disable the endpoint. Acknowledge fast and do heavy work asynchronously.
* At-least-once and unordered: dedupe on the body's `id` (stable across replays) and use `sequence` to discard stale updates.

## Common Event Types

The event name is the `type` field of the JSON body.

| Event | Description |
| --- | --- |
| `customer.created` | `POST /v2/customer` succeeded |
| `customer.pending` | All required criteria resolved for a verification profile |
| `customer.approved` | Verification profile approved |
| `customer.rejected` | Verification profile rejected |
| `transfer.created` | `POST /v2/transfer` succeeded |
| `transfer.fundsReceived` | Funds detected from the customer |
| `transfer.succeeded` | Terminal success: funds delivered |
| `transfer.failed` | Terminal failure |

Others: `transfer.pendingFunding`, `transfer.pendingReview`, `transfer.processing`, `transfer.returned`, `transfer.pendingRefundInformation`, `transfer.refunded`, `transfer.canceled`, `transfer.expired`, `transfer.failedPrecondition`, `transfer.unexpectedError`. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/spherepay-webhooks/references/overview.md).

> **For the full event reference**, see [SpherePay's event catalog](https://docs.spherepay.co/concepts/webhooks/event-catalog).

## Important Headers

| Header | Description |
| --- | --- |
| `Sphere-Signature` | Bare lowercase hex HMAC-SHA256 of `{timestamp}.{raw body}` |
| `Sphere-Timestamp` | Unix seconds when this attempt was sent (signed) |
| `Sphere-Delivery-Id` | The event delivery this attempt belongs to |
| `Sphere-Delivery-Attempt` | Attempt counter, starting at 1 |
| `Sphere-Delivery-Type` | `original` or `replay` |

## Environment Variables

```bash
SPHEREPAY_WEBHOOK_SECRET=whsec_your_endpoint_signing_secret   # returned once when the endpoint is created
SPHEREPAY_WEBHOOK_TOLERANCE_SECONDS=0   # optional; a positive value opts in to the replay window

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 spherepay --path /webhooks/spherepay

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/spherepay-webhooks/references/overview.md) - SpherePay webhook concepts and events
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/spherepay-webhooks/references/setup.md) - Registering an endpoint and getting the secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/spherepay-webhooks/references/verification.md) - Signature verification details and the published test vector