---
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 `references/event-payloads.md` |
| Manage an existing subscription; run the receiver locally | `references/subscription-management.md` |
| Signature code for a specific language/framework | `references/signature-verification.md` |
| Deliveries missing or failing verification | `references/delivery-troubleshooting.md` |

Read a reference file when you reach the step that needs it, not before.

## 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 the table in `references/subscription-management.md` § "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" in the same file. |
| **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 an unprefixed server-side variable. `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. 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.

Per-event payload examples and field semantics are in `references/event-payloads.md`; read
the section for each event you handle before writing its handler.

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. `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.
- **`data.uuid` semantics vary by event.** For `order.*` it's the order UUID (usable with
  `GET /v2/order/{uuid}`); for `customer.tokenized` the customer UUID is at
  `data.customer.uuid`; `dispute.*` uses `data.order_reference_id`.

## 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:
`references/signature-verification.md`.

## 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. Read, merge, then PATCH.

URL validation rules, the 400/409 error bodies, `GET`/`PATCH`/`DELETE`, and local tunneling:
`references/subscription-management.md`.

## 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):**

- `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: `references/delivery-troubleshooting.md`.

## Pitfalls

- **Failing endpoints lose their subscription.** A non-2xx (or no response within 60s) is
  retried over about five days (schedule in `references/delivery-troubleshooting.md`).
  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 `references/delivery-troubleshooting.md`.
- **Sandbox and production subscriptions are separate** (base URLs, keys, dashboards).

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

## Source docs

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