---
name: sezzle-webhooks
description: >
  Subscribe to and handle Sezzle webhook events — order authorization, capture,
  refund, customer tokenization, and disputes — including HMAC-SHA256 signature
  verification. Use when: "set up Sezzle webhooks", "handle order.authorized events",
  "verify the Sezzle-Signature header", "get notified when a Sezzle order is captured
  or refunded", "handle Sezzle dispute events". Assumes API
  credentials exist; for full checkout integration use sezzle-checkout first.
metadata:
  author: Sezzle
  version: "1.0.0"
---

# Handle Sezzle Webhooks

Check what exists → build the receiver → verify signatures → subscribe → test.

## Pick the path first

| Goal | Where to look |
|---|---|
| Order events (`order.authorized`, `order.captured`, `order.refunded`) | Steps 1–5 below |
| `customer.tokenized` | Steps 1–5, then `sezzle-tokenization` for charging the stored UUID |
| `dispute.*` events | Steps 1–5; payload fields in § "Event payloads" |
| Manage an existing subscription; run the receiver locally | § "Subscription management" |
| Signature code for a specific language/framework | § "Signature verification code" |
| Deliveries missing or failing verification | § "Delivery troubleshooting" |

## Optional: load API context

This skill is self-contained. For extra detail, read these pages with the `sezzle-docs` MCP
tool `query_docs_filesystem_sezzle` (install with
`npx add-mcp https://docs.sezzle.com/mcp --name sezzle-docs`):

1. `cat /docs/api/core/webhooks/infov2webhooks.mdx` — payload shapes, signature, delivery
2. `cat /docs/api/core/webhooks/postv2webhooks.mdx` — subscription endpoint

## Environments

| | Sandbox | Production |
|---|---|---|
| API base | `https://sandbox.gateway.sezzle.com/v2` | `https://gateway.sezzle.com/v2` |
| Dashboard | `https://sandbox.dashboard.sezzle.com/merchant` | `https://dashboard.sezzle.com/merchant` |

Keys, orders and webhook subscriptions are environment-specific. A sandbox subscription
receives sandbox events only; production needs its own.

## Pre-flight checks

Run every check before writing code. Each ends in "proceed" or a hand-off; none is optional
because the dev usually can't tell from the outside that it matters.

| Check | How | If it fails |
|---|---|---|
| **Sezzle account approved in the target environment** | Ask. Sandbox and production are separate sign-ups and separate applications. | Stop and use the `sezzle-prototype-sandbox` skill. Do not guess at a signup flow. |
| **API keys reachable from the codebase** | Grep `SEZZLE` in config and `.env`; names are often environment-suffixed (`SEZZLE_SANDBOX_PRIVATE_KEY`). | Only an Admin dashboard user can generate keys (Dashboard → Settings → API Keys). Stop and ask. |
| **A working Sezzle checkout exists** — needed for `order.*` and `customer.tokenized` | Grep for `/v2/session`, the Sezzle JS SDK, or a platform plugin. Assume whatever exists works; don't assume how it was built. | Hand off to `sezzle-checkout`. Order events only fire for orders that checkout created. Skip for dispute-only subscriptions: `dispute.*` fires regardless of how the order was created. |
| **Which `intent` the checkout sends** | Read the session body (`order.intent`), or the plugin/dashboard setting. | Not a failure, but it fixes the event list: **`CAPTURE` fires `order.captured` and never `order.authorized`; `AUTH` fires `order.authorized` and nothing else** — merchant-initiated capture, release, reauthorize or expiry emits no webhook. Subscribing to the wrong one is a silent hang. Tell the dev now. |
| **The Sezzle order UUID is persisted** | Find where `order.uuid` from the `POST /v2/session` 201 is stored against the merchant order. | `order.*` payloads identify the order by `data.uuid` only — no `reference_id`. Without the stored UUID the events can't be correlated. Hand off to `sezzle-checkout` Step 4. |
| **The receiver URL passes the destination policy** | Compare the intended URL against § "URL rules". | `localhost`, `http://`, an IP, or a non-443 port gets a 400 from `POST /v2/webhooks`, even in sandbox. For local work see § "Local development". |
| **The private key is loadable by the receiver process** | If the receiver is the same service as checkout, it already has it. If it's a separate service or function, check its config. | Provision the private key as a server-side variable, never one with a client-exposed prefix (`NEXT_PUBLIC_`, `VITE_`). `Sezzle-Signature` is verified with the private key, not a per-subscription secret. |

## Events

| Event | Trigger | `data_type` |
|---|---|---|
| `customer.tokenized` | A customer is tokenized | `tokenize` |
| `order.authorized` | An order is authorized by Sezzle | `order` |
| `order.captured` | An order is captured by Sezzle | `order` |
| `order.refunded` | An order is refunded by Sezzle | `order` |
| `dispute.merchant_input_requested` | Shopper filed a dispute; merchant input required | `dispute` |
| `dispute.deadline_approaching` | Dispute moved to final notice | `dispute` |
| `dispute.closed.customer_win` | Shopper wins; order refunded | `dispute` |
| `dispute.closed.merchant_win` | Merchant wins | `dispute` |
| `dispute.closed.neutral` | Resolved neutrally | `dispute` |

Other event families exist (e.g. `virtualcard.*`). This skill covers the events above; return
2xx for any event you didn't subscribe to (Step 2 rules).

The `data_type` list in `infov2webhooks.mdx` says `customer`; the actual delivered value is
`tokenize`, matching the example on the same page.

## Step 1: Authenticate

Reuse the checkout's token/refresh logic if there is one. Otherwise:

```
POST /v2/authentication
{ "public_key": "...", "private_key": "..." }
```

Returns `{ "token": "...", "expiration_date": "...", "merchant_uuid": "..." }`. Use
`Authorization: Bearer {token}` on webhook management calls. Tokens expire after 120 minutes.
A 401 means re-authenticate and retry once.

## Step 2: Build the receiver endpoint

Create a POST endpoint. Every event delivers the same envelope:

```json
{
  "uuid": "<webhook event uuid>",
  "created_at": "<ISO 8601>",
  "event": "<event name>",
  "data_type": "order | tokenize | dispute | …",
  "data": { ... }
}
```

Route by `event`:

| Event | Do |
|---|---|
| `order.authorized` | Mark payable. Capture before `data.authorization.expiration` (set per merchant, 30 minutes to 7 days; 30 minutes default, 7 days on Shopify). |
| `order.captured` | Mark paid. |
| `order.refunded` | One event per refund. Add `data.refund.amount.amount_in_cents` to a running total; fully refunded only when it covers the order. Ignore the amount when `data.refund.source` is `gateway` — you issued that refund and already recorded it. |
| `customer.tokenized` | Store `data.customer.uuid` **and** `data.customer.expiration`. Not `data.token`. |
| `dispute.*` | Alert operations; correlate on `data.order_reference_id`. `merchant_input_requested` and `deadline_approaching` are time-sensitive. |

**Only one of the first two rows can ever fire, and the intent fixes which** (pre-flight).
They are not two stages of one order.

Capture is the money movement, and only Sezzle's own capture emits a webhook. Under
`CAPTURE` Sezzle captures at checkout, so `order.captured` arrives exactly once. Under `AUTH`
you call `POST /v2/order/{uuid}/capture` yourself and nothing is emitted, so settle the order
from that call's response rather than waiting for an event that never comes.

Before writing each handler, read its payload and correlation key in § "Event payloads".

Rules for the endpoint:

- **Return a 2xx quickly; process asynchronously.** Any 2xx (200, 202, 204) counts as
  delivered. Anything else is retried, and repeated failures delete the subscription
  (see Pitfalls).
- **Return 2xx for events you can't match, too.** Unknown `data.uuid`, unhandled event type,
  someone else's order: log and acknowledge. A 404 gets retried over five days, and if the
  last attempt fails the subscription is deleted. Step 5's test delivery lands here — its
  `data.uuid` matches no order of yours.
- **Deduplicate on the webhook `uuid`.** Retries resend the same payload with the same
  `uuid`, so it's a stable idempotency key and the only replay protection. Keep seen UUIDs
  at least five days (retries can span that long).
- **Don't assume events arrive in order.** A retry of an older event can land after a newer
  one; `created_at` is when the event was generated, not delivered. `order.refunded` may
  arrive before the `order.captured` it refunds. Handlers must tolerate any order; don't
  build a state machine that requires a sequence.

## Step 3: Verify signatures

Verify **before** doing anything else with the payload. `Sezzle-Signature` is the lowercase
hex HMAC-SHA256 digest (64 characters) of the raw request body, keyed with your **merchant
private key** — the same one used for `POST /v2/authentication`.

```
Sezzle-Signature: 3f8a1c...   (64 hex chars, nothing else)
```

Don't carry over a Stripe-style scheme; this differs in a few ways:

- **Sign the raw body bytes exactly as received.** Capture the raw body before JSON parsing.
  Re-serializing changes key order and whitespace, breaking the digest.
- **The header is the bare digest.** No `t=`/`v1=` structure, no version prefix. Compare it
  whole.
- **No timestamp, no nonce.** There is nothing to build a tolerance window from. Replay
  protection is the `uuid` dedupe in Step 2.
- **Constant-time compare** — `hmac.compare_digest` (Python), `crypto.timingSafeEqual` (Node),
  `hmac.Equal` (Go). Never `==` on the digest string.
- **Key rotation re-signs in-flight retries.** A delivery retried after a private-key
  rotation is signed with the new key.
- **Verification failures count as failed deliveries.** A 401 (or any non-2xx) from a bad
  signature burns the retry budget the same as a timeout. Update the receiver's copy of the
  key before rotating it in the dashboard, or every in-flight and future delivery fails
  verification until the subscription is silently deleted (see Pitfalls). Alert on your
  receiver's 401 rate.

Framework-specific raw-body access and complete verifier snippets:
§ "Signature verification code".

## Step 4: Subscribe

```
POST /v2/webhooks
Authorization: Bearer {token}
{ "url": "https://merchant.example/webhooks/sezzle",
  "events": ["order.captured", "order.refunded"] }
```

Pick `events` from the intent check in pre-flight, not from the full list.

Two rules that bite:

- **One URL, one subscription, many events.** The URL is unique per merchant; a second
  subscription on the same URL fails. Don't create one per event.
- **`PATCH` replaces the event list wholesale.** Sending only the new events silently
  unsubscribes the rest. To add an event:
  1. `GET /v2/webhooks/{uuid}` — read the current `events`.
  2. Union with the events you want to add.
  3. `PATCH` with the same `url` and the full merged list.
  4. `GET` again and confirm.

URL validation rules, the 400/409 error bodies, `GET`/`PATCH`/`DELETE`, and local tunneling:
§ "Subscription management".

## Step 5: Test

Sezzle has to deliver to you, and a real order needs a shopper in a browser, so you can't
verify everything alone. Say which half you verified.

**Verify unaided (do all of it):**

- Before subscribing, compute a digest locally over a sample body and send it yourself.
  Expect 2xx:

  ```bash
  BODY='{"uuid":"test","created_at":"2025-04-12T00:15:39Z","event":"order.captured","data_type":"order","data":{"uuid":"x"}}'
  SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SEZZLE_PRIVATE_KEY" | sed 's/^.* //')
  curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3001/webhooks/sezzle \
    -H "Content-Type: application/json" -H "Sezzle-Signature: $SIG" --data-binary "$BODY"
  ```

- `POST /v2/webhooks` returns 200 and `GET /v2/webhooks` lists the subscription with the
  events you meant.
- Trigger a synthetic delivery:

  ```
  POST /v2/webhooks/test
  Authorization: Bearer {token}
  { "event": "order.captured", "url": "https://merchant.example/webhooks/sezzle" }
  ```

  Returns `201`. The delivery is signed like a real one and carries a populated example
  payload, so it exercises the whole verifier. Confirm the receiver logged the raw request,
  verified the signature, and returned a 2xx. `url` need not be subscribed yet; omit it to
  send to every URL subscribed to that event.
- Replay the same body and header: the handler must be a no-op on the duplicate `uuid`.
- Flip one byte of the body, resend with the original header: must reject before any
  processing.
- **This endpoint is not restricted to sandbox.** The same call against production credentials
  sends a synthetic event to a live endpoint, so point `url` at something harmless when
  experimenting.

**Human handoff for the rest.** Ask the user to complete a sandbox checkout (cart total
$20–$2500, US/CA IP, shopper OTP `123123`, Visa test card `4242 4242 4242 4242`), then confirm
the event matching the checkout's intent arrived and verified. For `order.refunded`, refund
from the sandbox dashboard or via `POST /v2/order/{uuid}/refund` and confirm `source` is
`"dashboard"` or `"gateway"` accordingly.

If nothing arrives, or it arrives and fails verification: § "Delivery troubleshooting".

## Pitfalls

- **Failing endpoints lose their subscription.** A non-2xx (or no response within 60s) is
  retried over about five days (schedule in § "Delivery troubleshooting", check 2).
  If the last attempt fails, *every* subscription on that
  URL is deleted and no further events are sent. Recreate the webhook to resume. Sezzle
  emails the merchant's default notification recipient when retries are nearly exhausted and
  again after deletion, but exposes no delivery log. Verify that recipient reaches a
  monitored inbox, and monitor your receiver's error rate independently.
- **WAF / CDN bot rules block deliveries.** Exempt the webhook path from bot rules; keep
  verifying the signature in the application. Details in § "Delivery troubleshooting", check 6.

## After sandbox works

Remind the user: webhook subscriptions are per-environment, so production needs its own
subscription, verified with the production private key. Use `sezzle-go-live` for the full
cutover.

## Event payloads

### Correlation key per event

| Event | Your handle | Where |
|---|---|---|
| `order.*` | Sezzle order UUID you stored at session creation | `data.uuid` (usable with `GET /v2/order/{uuid}`) |
| `customer.tokenized` | Customer UUID to store now | `data.customer.uuid` |
| `dispute.*` | Your own `reference_id` from the session | `data.order_reference_id` (`data.order_uuid` is optional) |

### `order.authorized`

```json
{
  "uuid": "fdb263a1-a1dd-4feb-8749-c8a447977ebb",
  "created_at": "2025-04-12T00:15:39.281826Z",
  "event": "order.authorized",
  "data_type": "order",
  "data": {
    "uuid": "f36605a0-4a96-46d1-9d01-a0b17140dc57",
    "authorization": {
      "created_at": "2025-04-12T00:15:39.276841Z",
      "authorization_amount": { "amount_in_cents": 5000, "currency": "USD" },
      "approved": true,
      "expiration": "2025-04-12T00:45:39.276841Z"
    }
  }
}
```

`approved` is always `true`; a declined authorization emits nothing.

### `order.captured`

```json
{
  "uuid": "6ee025c6-8acf-48fe-a6d6-b51693d64c60",
  "created_at": "2025-04-12T00:20:07.44668Z",
  "event": "order.captured",
  "data_type": "order",
  "data": {
    "uuid": "b87305a1-6be3-4877-bcf0-2b5b7dfaeaf0",
    "capture": {
      "uuid": "b69b1ba8-e977-4f26-9253-06f14d941696",
      "created_at": "2025-04-12T00:20:07.438365Z",
      "amount": { "amount_in_cents": 3000, "currency": "USD" }
    }
  }
}
```

### `order.refunded`

Orders created through a platform plugin don't get this event for a dashboard refund.

```json
{
  "uuid": "ed89f046-f55e-4fdd-9f65-ec3d8e961f99",
  "created_at": "2025-04-12T00:24:39.809499Z",
  "event": "order.refunded",
  "data_type": "order",
  "data": {
    "uuid": "b69f882d-06c3-4f2e-b6e3-ce7a6c46e455",
    "refund": {
      "uuid": "479e9d25-d0a5-49df-a2d3-6ca1d75b8c57",
      "created_at": "2025-04-12T00:24:39.805651Z",
      "source": "dashboard",
      "amount": { "amount_in_cents": 500, "currency": "USD" }
    }
  }
}
```

For totals, `GET /v2/order/{uuid}` and sum `authorization.refunds[].amount.amount_in_cents`.

### `customer.tokenized`

```json
{
  "uuid": "e41c32d5-687d-414f-b5c6-d089bea52e7d",
  "created_at": "2025-04-12T00:11:01.749261Z",
  "event": "customer.tokenized",
  "data_type": "tokenize",
  "data": {
    "token": "ce56604a-5dfd-489a-80e9-753d0325dd46",
    "expiration": "2025-04-12T00:41:01.745145Z",
    "customer": {
      "uuid": "e0003e6a-7234-4440-9265-61906e8b8879",
      "created_at": "2025-04-12T00:11:01.745145Z",
      "expiration": "2026-04-12T00:11:01.745145Z"
    }
  }
}
```

Two expirations — do not conflate them. `data.token` / `data.expiration` is the short-lived
tokenization *request* token (minutes), not what you store. `data.customer.expiration` is how
long the stored `data.customer.uuid` stays chargeable, typically about a year.

### `dispute.*`

All five dispute events share one shape:

```json
{
  "uuid": "79f1e9cd-f1ef-42fa-b7b4-2ed8d9e9fae8",
  "created_at": "2025-04-11T17:22:22.717757Z",
  "event": "dispute.merchant_input_requested",
  "data_type": "dispute",
  "data": {
    "customer_name": "John Doe",
    "order_uuid": "e17280f3-d575-4bc2-99ff-ff881df7b137",
    "order_reference_id": "order-reference-id-123",
    "sezzle_order_id": "d2s7t-281qj-rf6ra-dqfcg",
    "order_date": "2024-08-20",
    "dispute_type": "No Product Or Service",
    "dispute_due_date": "2025-04-25",
    "dispute_id": 132,
    "dispute_amount_in_cents": 2500,
    "dispute_currency": "USD",
    "dispute_status": "Awaiting Merchant Response"
  }
}
```

- `dispute_status` has exactly one value per event:

  | Event | `dispute_status` |
  |---|---|
  | `dispute.merchant_input_requested` | `Awaiting Merchant Response` |
  | `dispute.deadline_approaching` | `Merchant Final Notice` |
  | `dispute.closed.customer_win` | `Closed Merchant Loss` |
  | `dispute.closed.merchant_win` | `Closed Customer Loss` |
  | `dispute.closed.neutral` | `Closed All Win` |

  Route on `event`, not the status text. (The docs example pairs `merchant_input_requested`
  with `Closed All Win`; that combination never occurs.)
- `dispute_due_date` is populated on `merchant_input_requested` and `deadline_approaching`
  only. The three `dispute.closed.*` events have the key but with an empty string.
- **`dispute.closed.customer_win` does not produce an `order.refunded` webhook.** Sezzle
  books that refund internally. Treat the dispute event itself as the refund signal.

## Signature verification code

Read only the section for the receiver's language. The signature has no timestamp and no
per-subscription secret; replay protection and key rotation are in Step 3.

### Python

#### FastAPI / Starlette

```python
import hmac, hashlib, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
PRIVATE_KEY = os.environ["SEZZLE_PRIVATE_KEY"].encode()

@app.post("/webhooks/sezzle")
async def sezzle_webhook(request: Request):
    raw = await request.body()          # bytes as received; do not use request.json() first
    expected = hmac.new(PRIVATE_KEY, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("Sezzle-Signature", "")
    if not hmac.compare_digest(expected, received):
        raise HTTPException(status_code=401)
    # enqueue `raw` for async processing, then:
    return {"ok": True}                 # 200
```

`request.body()` caches, so a later `.json()` sees the same bytes. A middleware that consumes
the stream first leaves the handler with nothing; read the body in the handler or have the
middleware pass it along.

#### Flask

Use `request.get_data()`, not `request.json`:

```python
raw = request.get_data()
expected = hmac.new(PRIVATE_KEY, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("Sezzle-Signature", "")):
    abort(401)
```

### Node

#### Express

Mount `express.raw()` on the webhook route so `req.body` is a `Buffer`. A global
`express.json()` registered earlier drains the stream and leaves `express.raw()` an **empty**
Buffer; register the webhook route before it, or use `express.json()`'s `verify` option to
stash the raw buffer.

```js
const crypto = require("crypto");
const express = require("express");

const app = express();
const PRIVATE_KEY = process.env.SEZZLE_PRIVATE_KEY;

app.post("/webhooks/sezzle", express.raw({ type: "*/*" }), (req, res) => {
  const expected = crypto.createHmac("sha256", PRIVATE_KEY).update(req.body).digest("hex");
  const received = req.get("Sezzle-Signature") || "";
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(received, "utf8");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }
  // enqueue req.body for async processing, then:
  res.sendStatus(200);
});
```

`timingSafeEqual` throws on unequal lengths, hence the length check first.

### Go

```go
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
)

var privateKey = []byte(os.Getenv("SEZZLE_PRIVATE_KEY"))

func sezzleWebhook(w http.ResponseWriter, r *http.Request) {
    raw, err := io.ReadAll(r.Body)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    mac := hmac.New(sha256.New, privateKey)
    mac.Write(raw)
    expected := hex.EncodeToString(mac.Sum(nil))
    if !hmac.Equal([]byte(expected), []byte(r.Header.Get("Sezzle-Signature"))) {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }
    // enqueue raw for async processing, then:
    w.WriteHeader(http.StatusOK)
}
```

### Ruby / Rails

```ruby
raw = request.raw_post                                        # request.body.read in plain Rack
computed = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("SEZZLE_PRIVATE_KEY"), raw)
provided = request.headers["Sezzle-Signature"].to_s
head :unauthorized and return unless ActiveSupport::SecurityUtils.secure_compare(computed, provided)
```

Outside Rails use `Rack::Utils.secure_compare` or `OpenSSL.secure_compare`.

### ASP.NET Core

Run this in middleware, before model binding.

```csharp
request.EnableBuffering();
using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms);
request.Body.Position = 0;
byte[] raw = ms.ToArray();

byte[] mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(privateKey), raw);   // .NET 6+
string computed = Convert.ToHexStringLower(mac);   // .NET 9+; on 6–8: Convert.ToHexString(mac).ToLowerInvariant()
string provided = request.Headers["Sezzle-Signature"].ToString();
bool ok = CryptographicOperations.FixedTimeEquals(
    Encoding.UTF8.GetBytes(computed), Encoding.UTF8.GetBytes(provided));
```

`Convert.ToHexString` is **uppercase**; the header is lowercase.

### Django

`request.body` for the raw bytes (before anything calls `request.read()`), then the Flask
snippet's `hmac` lines.

## Subscription management

All endpoints need `Authorization: Bearer {token}`. Every route is plural.

| Operation | Endpoint | Notes |
|---|---|---|
| Create | `POST /v2/webhooks` | Body `{ "url", "events": [...] }`, both required. 200 with `uuid` and `links`. |
| List | `GET /v2/webhooks` | All subscriptions for the merchant in this environment. |
| Read one | `GET /v2/webhooks/{webhooks_uuid}` | |
| Update | `PATCH /v2/webhooks/{webhooks_uuid}` | Body `{ "url", "events": [...] }`, **both required**. `events` replaces the whole list (Step 4). |
| Delete | `DELETE /v2/webhooks/{webhooks_uuid}` | |
| Test | `POST /v2/webhooks/test` | Body `{ "event" }`, `"url"` optional (omit to hit all subscribed URLs). 201. See Step 5. |

Actual error responses (confirmed against sandbox 2026-09-08; bodies are JSON arrays):

| Operation | Case | Status / body |
|---|---|---|
| `POST` | duplicate URL | 409 `[{"code":"record_exists","message":"Duplicate webhooks url","location":"url"}]` |
| `POST` / `PATCH` | URL fails a rule below | 400 `[{"code":"invalid","location":"url","message":"URL …"}]` |
| `GET {uuid}` | unknown uuid | 404 `[{"code":"record_not_found","message":"webhook not found","debug_uuid":"…"}]` |
| `PATCH {uuid}` | duplicate URL **or** unknown uuid | **500** `[{"code":"api_error","message":"unexpected error"}]` (docs say 422/404). Not an outage: `GET /v2/webhooks` and compare. |
| `DELETE {uuid}` | unknown uuid | **204**, empty, same as a real delete (docs say 404) |

### URL rules

The gateway validates `url` on `POST`, `PATCH`, and `POST /v2/webhooks/test` when a `url` is
supplied. **Enforced in sandbox the same as production.** First failing rule wins:

| Rule | Message |
|---|---|
| Absolute URL | `URL must be a valid absolute URL` |
| Scheme is `https` | `URL must use https` |
| No `user:pass@` | `URL must not contain credentials` |
| Has a hostname | `URL must include a hostname` |
| Port omitted or `443` | `URL must use the default https port` |
| Hostname, not an IP literal | `URL must be a hostname, not an IP address` |
| Not `localhost`, not `.local` / `.internal` / `.svc` / `.cluster.local` / `.localdomain` | `URL must not be an internal hostname` |
| Contains a dot (FQDN) | `URL must be a fully qualified hostname` |
| DNS resolves only to public IPs (best-effort, short timeout) | `URL must not resolve to a private or reserved address` |

Empty `url` → 400 `{ "code": "missing", "message": "URL is required", "location": "url" }`.

`PATCH` re-checks URL uniqueness only when the URL changes. These rules aren't in the public
docs. At delivery time the sender follows redirects and may refuse hosts that resolve to
private addresses; keep the receiver on a public host.

### Local development

`http://localhost:3001/...` can never be subscribed. Expose the local receiver through a
tunnel that gives you an `https://` FQDN on port 443 with public DNS:

```bash
ngrok http 3001                                  # https://<random>.ngrok-free.app, new name per run
ngrok http --domain=<name>.ngrok-free.app 3001   # stable name; claim the free dev domain once first
cloudflared tunnel --url http://localhost:3001   # https://<random-words>.trycloudflare.com, new per run
```

Subscribe with `https://<tunnel-host>/webhooks/sezzle`.

Gotchas:

- **Random tunnel URLs change every restart.** `PATCH` the subscription or `DELETE` and
  recreate; don't accumulate one per session. ngrok's `--domain` avoids the churn.
- **ngrok's inspector** (`http://127.0.0.1:4040`) shows the exact bytes and headers Sezzle
  sent, the fastest way to distinguish "never delivered" from "handler rejected it".
  cloudflared has no inspector; log the raw request in the handler instead.
- **A closed tunnel kills the subscription** after five days of failed retries. Delete it
  when you're done, or expect to recreate it.
- **Sandbox only.** Never point a production subscription at a tunnel.

## Delivery troubleshooting

Work top to bottom; each check is cheap and rules out the ones below it. For anything beyond
delivery (401s, capture failures, order state) use `sezzle-troubleshoot`.

### Nothing arrives

1. **Does this action emit a webhook at all?**
   `CAPTURE` intent fires only `order.captured`; `AUTH` fires only `order.authorized`.
   Merchant-initiated capture, release, reauthorize or expiry emit nothing — read the API
   response instead. `GET /v2/webhooks` and compare `events` with the checkout's
   `order.intent`. This is the most common cause, and it produces no error anywhere.

2. **Does the subscription still exist?**
   `GET /v2/webhooks`. On failure Sezzle retries multiple times in the first hour, a few
   more throughout the day, and makes final attempts at roughly one day and three days later,
   spanning about five days total. If the last attempt fails Sezzle silently deletes every
   subscription on that URL. A long weekend of downtime is enough. A subscription can also
   be removed if the URL no longer passes the destination policy (e.g. DNS now resolves to a
   private address). Recreate it.

3. **Right environment?**
   A sandbox subscription receives sandbox events only. Check which base URL created the
   subscription and which one created the order.

4. **Did the event actually happen?**
   `GET /v2/order/{uuid}`: is there an `authorization`, a `captures` entry, a `refunds`
   entry? If not, the checkout didn't complete or the capture didn't happen; that's a
   checkout problem, not a webhook problem.

5. **Is the URL reachable from the public internet?**
   Not from your laptop — from outside. `curl -X POST https://<your-url>` from another
   network, or a tunnel inspector showing zero inbound requests, tells you. If the
   subscription exists the URL passed the rules at creation time, but DNS can have changed
   since. See § "URL rules".

6. **WAF / CDN / bot mitigation in front of the receiver?**
   Deliveries are server-to-server POSTs with no browser headers, which bot rules often
   block. Exempt the webhook path from bot rules and keep verifying `Sezzle-Signature` in
   the application — admitting any request with that header authenticates nothing. Sezzle
   publishes no egress IPs; for an IP allowlist, contact the Account Manager.

7. **Use `POST /v2/webhooks/test` to separate "Sezzle can't reach me" from "no event".**
   201 + nothing at the receiver → check 5 or 6. 201 + failed verification → next section.

8. **Slow receiver?** The sender waits 60s per attempt and retries immediately on a 5xx or
   connection error, so a struggling receiver can see a few hits within seconds. That burst
   is one delivery attempt, not several, and every hit carries the same webhook `uuid`.

Beyond the emails in Pitfalls, your receiver log or the tunnel inspector is the only delivery
record.

### Arrives but fails verification

1. **Are you hashing the raw bytes?** Log `len(raw_body)` and the first bytes at the top of
   the handler; a body parsed and re-serialized before the HMAC never matches. Raw-body
   access per framework: § "Signature verification code".
2. **Right key?** The private key (`sz_pr_…`) of the environment that sent the event.
3. **Comparing the whole header?** A bare 64-char lowercase hex digest; no `t=…,v1=…`, no
   prefix to strip.
4. **Was the private key rotated?** In-flight retries are re-signed with the new key; the
   receiver must already have it.
5. **Is a proxy or framework altering the body?** Gzip transcoding, charset re-encoding, or
   a body-size limit that truncates will change the bytes. Compare the inspector's body
   length with what your handler received.
6. **Following a redirect?** The sender follows 3xx from your receiver. If the redirect
   target is a different handler or strips the header, verification fails there. Return 2xx
   at the subscribed URL directly.

### Arrives, verifies, but the order isn't updated

1. **`data.uuid` not found in your records:** the order UUID was never stored (pre-flight).
2. **Handler assumed ordering:** `order.refunded` can precede its `order.captured`; ignoring
   it drops a real refund.
3. **Dedupe swallowed a legitimate event:** dedupe on the webhook `uuid` only, not on
   `(event, order uuid)`. Two refunds on the same order have two distinct webhook UUIDs.
4. **Processing before responding:** a synchronous handler that times out gets the event
   retried with the first attempt half-applied. Respond 2xx first, process from a queue.

## Source docs

- `/docs/api/core/webhooks` (all pages) • `/docs/api/core/authentication/postauthentication`
