# Superwall Webhooks

Superwall ([superwall.com](https://superwall.com)) delivers subscription lifecycle events through
Svix, signed per Standard Webhooks with the `svix-*` header
names. Each delivery is an HTTP `POST` with a JSON body and three headers: `svix-id`,
`svix-timestamp`, and `svix-signature`. **Webhooks V2** is the current format: the body is
`{ id, api_version, type, occurred_at, project_id, app_user_id, ordering_key, event_data, subscription, previous, subscriber, ... }`.
Webhooks created before V2 are labelled **Legacy** and keep a camelCase body
(`{ object: "event", type, projectId, applicationId, timestamp, data }`). The two are told apart by the
`superwall_version` header (`2` or `1`), not by `type`. The event name is the top-level `type`,
lowercase, in both.

## When to Use This Skill

* How do I receive Superwall webhooks?
* How do I verify Superwall webhook signatures (`svix-signature`)?
* How do I handle `initial_purchase`, `renewal`, `cancellation` or `refund` events?
* How do I detect trial starts, trial conversions, billing issues and expirations?
* Why is my Superwall webhook signature verification failing, or rejecting Hookdeck retries?

## Verification (core)

Signed content is `{svix-id}.{svix-timestamp}.{raw body}`. Svix signs it with HMAC-SHA256 using
the base64-decoded secret (the part after `whsec_`), base64-encodes the result, and sends one or
more space-separated `v1,<signature>` entries. Use the official `svix` library, which compares in
constant time. Read the `svix-*` headers, and when `svix-id` is absent fall back to the `webhook-*`
headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`), as Hookdeck does.

```javascript
import { Webhook } from 'svix'; // svix 2.x is ESM-only

const prefix = req.headers['svix-id'] ? 'svix' : 'webhook';
const id = req.headers[`${prefix}-id`];
const timestamp = req.headers[`${prefix}-timestamp`];
const signature = req.headers[`${prefix}-signature`];
// rawBody: the raw Buffer/string, never re-serialized JSON
new Webhook(process.env.SUPERWALL_WEBHOOK_SECRET).verify(rawBody, {
  'svix-id': id, 'svix-timestamp': timestamp, 'svix-signature': signature,
  'webhook-id': id, 'webhook-timestamp': timestamp, 'webhook-signature': signature,
}); // throws on a bad signature or a timestamp outside the window
const event = JSON.parse(rawBody.toString('utf8')); // parse only after verify passes

```

```python
from svix.webhooks import Webhook, WebhookVerificationError

wh = Webhook(os.environ["SUPERWALL_WEBHOOK_SECRET"])  # "whsec_..."
wh.verify(raw_body, {"svix-id": id, "svix-timestamp": ts, "svix-signature": sig,
                     "webhook-id": id, "webhook-timestamp": ts, "webhook-signature": sig})

```

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

Each webhook has one signing secret (`whsec_` + base64). V2 shows it **once**, right after you create
the webhook (the API returns it on create and rotate). If you lose it, use **Rotate secret**, which
takes effect immediately. Keep the full value, prefix included, in `SUPERWALL_WEBHOOK_SECRET`.
Handlers fail closed: with the variable unset (or not a `whsec_` value) they return 500 instead of
accepting anything. Missing headers or a bad signature return 400. The `svix` library used here reads
either header set, so no mapping is needed beyond the fallback shown.

### Hookdeck retries and replays hit the 5-minute window

The `svix` and `standardwebhooks` libraries always enforce a fixed 5-minute timestamp window, and
it can't be configured. Hookdeck retries and replays resend the **original** `svix-timestamp`, so
the library rejects any retry or replay that happens more than 5 minutes after the original
delivery. If you verify behind Hookdeck (or replay old events), use the manual verifier in
[references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/superwall-webhooks/references/verification.md), where the window can be dropped or
widened. Deduplicate on the event `id` instead of relying on the timestamp.

## Common Event Types

12 event types are documented (the list may not be exhaustive). The payload `type` is lowercase; the
uppercase names (`RENEWAL`) are only the dashboard/API subscription filter. The examples handle all of them:

| Event | Triggered When |
| --- | --- |
| `initial_purchase` | A user starts a subscription or trial (`event_data.period_type` is `TRIAL` for trials) |
| `renewal` | A subscription renews, including a trial or intro offer converting to paid |
| `non_renewing_purchase` | A one-time purchase |
| `product_change` | A user switches product (`event_data.new_product_id`) |
| `cancellation` | A user turns off auto-renew; access continues until expiry |
| `uncancellation` | A user turns auto-renew back on |
| `refund` | A purchase is refunded; `price` and `proceeds` are negative |
| `billing_issue` | The store can't charge the user |
| `subscription_paused` | A user pauses (Google Play only) |
| `subscription_extended` | The store extends the expiration date |
| `expiration` | A subscription expires |
| `transferred` | Reserved for ownership changes; not sent yet |

Delivery is at least once and unordered: **deduplicate on `id`** (`evt_` + 32 hex, stable across
redelivery; legacy uses `data.id`) and order by `occurred_at`. See
[references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/superwall-webhooks/references/overview.md) for the payload shape and field details.

## Environment Variables

```bash
# Signing secret of the webhook (Svix format, starts with whsec_)
SUPERWALL_WEBHOOK_SECRET=whsec_xxxxx

```

## Local Development

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

```

Create a webhook under Integrations > Webhooks in the Superwall dashboard with the tunnel URL. Superwall
sends no test events to V2 webhooks, so trigger a sandbox purchase (TestFlight sandbox Apple ID, a Google
Play license tester, or Stripe test mode).

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/superwall-webhooks/references/overview.md) - Superwall webhook concepts, events, V2 and legacy payload shapes, delivery
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/superwall-webhooks/references/setup.md) - Create a webhook, get the secret, test with a sandbox purchase
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/superwall-webhooks/references/verification.md) - Svix verification details, header fallback, manual verifier, timestamp window