# Guide to Superwall Webhooks: Features and Best Practices

[Superwall](https://superwall.com) gives mobile and web apps paywalls and the subscription plumbing behind them, across the App Store, Google Play and Stripe. Its webhooks send subscription lifecycle events (trials starting, renewals, cancellations, refunds, billing problems, expirations) to your server, so you can grant or revoke access and track revenue in one place regardless of which store the purchase came from.

Superwall currently has two webhook formats, V2 and Legacy. This guide focuses on V2, explains how to tell them apart, and covers events, setup, Svix verification and the delivery rules.

## What are Superwall webhooks?

Superwall delivers webhooks through Svix, with `svix-id`, `svix-timestamp` and `svix-signature` headers. Its [Webhooks V2 documentation](https://superwall.com/docs/integrations/webhooks/v2) prints the signing steps on Superwall's own pages.

Every webhook created in the dashboard now uses **Webhooks V2**, which has a snake_case JSON body with millisecond timestamps and an `api_version` field (currently `2026-09-22`). Webhooks created before V2 are labelled **Legacy**, keep working and keep their older camelCase body. They're never converted. Dashboard-created webhooks send a `superwall_version` header, `2` for V2 and `1` for Legacy, which is the reliable way to tell them apart.

## Superwall webhook features

| Feature | Details |
| --- | --- |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `svix-signature` |
| Body | JSON |
| Event routing | Body field `type` |
| Delivery platform | Svix |
| Prerequisite | Revenue tracking set up |
| Configuration | Dashboard or API (`format=superwall_v2`) |
| Formats | V2 and Legacy, told apart by `superwall_version` |
| Secret | `whsec_` plus base64, shown once; rotation is immediate |
| Custom headers | Optional; `Authorization` is stripped |
| Retries | Exponential backoff after non-`2xx` or timeout; no schedule published |
| Guarantees | At least once, unordered |
| Test events | None for V2 |

## Events and payloads

Superwall documents 12 types. The `type` in the payload is lowercase:

| Event | Triggered When |
| --- | --- |
| `initial_purchase` | A user starts a subscription or trial |
| `renewal` | A subscription renews, including a trial or intro offer converting to paid |
| `non_renewing_purchase` | A user makes a one-time purchase |
| `product_change` | A user switches to a different product (`event_data.new_product_id`) |
| `cancellation` | A user turns off auto-renew; the subscription stays active until it expires |
| `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 their subscription (Google Play only) |
| `subscription_extended` | The store extends a subscription's expiration date |
| `expiration` | A subscription expires |
| `transferred` | Reserved for ownership changes; not sent yet |

The dashboard and API filters use uppercase names (`RENEWAL`), but deliveries always carry lowercase ones.

A V2 body is built around these fields:

* `id` (`evt_` plus 32 hex characters), stable across redelivery. Deduplicate on it.
* `type`, and `occurred_at` for ordering.
* `event_data`: the facts of the store event, for revenue. Useful fields include `store` (`APP_STORE`, `MAC_APP_STORE`, `PLAY_STORE`, `STRIPE`, `CUSTOM`), `environment` (`PRODUCTION` or `SANDBOX`), `period_type` (`NORMAL`, `TRIAL`, `INTRO`, `PREPAID`, `PROMOTIONAL`), `is_trial_conversion`, `cancel_reason` and `expiration_reason`. `event_data.name` repeats the store's event name.
* `subscription` and `previous`: the current and earlier subscription state. `subscription.is_active` decides access, and `subscription.sequence` orders snapshots.
* `subscriber`: aliases and user attributes.

Sometimes a store notification reaches Superwall before your app reports who made the purchase. Superwall then holds the event for up to 48 hours. If the owner turns up, it sends the event once with `app_user_id` and `subscriber` filled in; otherwise it sends it once after the hold with `app_user_id`, `subscriber` and `subscription` set to `null`, and doesn't resend it later. In that case match on `event_data.original_app_user_id` and `event_data.original_transaction_id`.

A Legacy body looks like `{ "object": "event", "type": "renewal", "projectId": 3827, "applicationId": 1, "timestamp": 1754067715103, "data": { "id": "...:renewal", "name": "renewal", "periodType": "NORMAL", ... } }`. Its event ID is `data.id`, and a Legacy refund is a `cancellation` with `isRefund: true` rather than a separate `refund` event.

## Setting up Superwall webhooks

Set up [revenue tracking](https://superwall.com/docs/dashboard/dashboard-settings/overview-settings-revenue-tracking) first; until then the Webhooks integration shows **Needs revenue tracking**.

**In the dashboard:**

1. Open your app, go to **Integrations** and choose **Webhooks** under **Webhooks and tools**.
2. Click **Create webhook** and enter a name and an HTTPS URL, such as `https://example.com/webhooks/superwall`. The URL can't contain credentials.
3. Choose events. All are selected by default; `initial_purchase`, `renewal`, `cancellation`, `refund`, `billing_issue` and `expiration` cover most needs.
4. Optionally add custom headers, such as `X-API-Key`. Superwall strips any `Authorization` header, so use another name for credentials.
5. Copy the signing secret. It is only shown once. Store it as `SUPERWALL_WEBHOOK_SECRET`.

**Through the API**, with a key that has `webhooks:read` and `webhooks:write`. Add `format=superwall_v2` to every webhook request, or the API manages Legacy webhooks:

```bash
curl -X POST "https://api.superwall.com/v2/projects/$PROJECT_ID/webhook_endpoints?format=superwall_v2" \
  -H "Authorization: Bearer $SUPERWALL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/webhooks/superwall", "filter_types": ["INITIAL_PURCHASE", "RENEWAL", "REFUND", "EXPIRATION"]}'

```

The response includes the `secret`. Leave out `filter_types` to receive every type, including ones added later.

V2 has no test events, so make a sandbox purchase to check your setup. The [Hookdeck CLI](/docs/cli) can forward those deliveries to your machine:

```bash
npx hookdeck-cli listen 3000 superwall --path /webhooks/superwall

```

**With Hookdeck.** Create a **Superwall** source, use its URL for the webhook and paste the secret Superwall shows into it. Superwall gets a `200` back for each `POST`. Deliveries with a signature mismatch are marked as failed verification in Hookdeck and go no further. Other HTTP methods are rejected. After a **Rotate secret**, update the source at once.

## Securing Superwall webhooks

Superwall's verify page prints the Svix method: sign `{svix-id}.{svix-timestamp}.{raw body}` with HMAC-SHA256, using the base64-decoded part of the secret after `whsec_`, and compare `v1,<signature>` with each space-separated entry in `svix-signature`. Its troubleshooting notes that the secret should start with `whsec_`. A version with no dependencies:

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

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

A V2 handler that keeps access in sync and records revenue separately:

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

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

const app = express();

app.post('/webhooks/superwall', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.SUPERWALL_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
  if (req.headers['superwall_version'] === '1') return res.sendStatus(200); // Legacy handled elsewhere

  const event = parseJsonObject(req.body);
  if (!event) return res.sendStatus(400); // signed, but not a JSON object
  if (!event.id || typeof event.type !== 'string') return res.sendStatus(400);
  try {
    if (await processed.has(event.id)) return res.sendStatus(200);

    await revenue.record(event.id, event.type, event.event_data);
    if (event.subscription && typeof event.subscription === 'object') {
      const { subscription_id, sequence, is_active } = event.subscription;
      await access.applyIfNewer(subscription_id, sequence, event.app_user_id, is_active);
    }
    await processed.add(event.id);
  } catch {
    return res.sendStatus(503); // not processed, so don't acknowledge
  }
  return res.sendStatus(200);
});

```

`processed`, `revenue` and `access` are your own code. Applying the snapshot only when its `sequence` is higher than the stored one keeps an out-of-order event from flipping access back.

Superwall's manual sample rejects timestamps more than 300 seconds old, and the Svix libraries do the same. Hookdeck applies no timestamp tolerance, and its retries and replays carry Superwall's original `svix-timestamp`, so that five-minute check would reject them. Behind Hookdeck, verify without it and rely on `id` deduplication instead.

> **Make Superwall webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) keeps every renewal and refund from the App Store, Google Play and Stripe, and lets you replay them into a new handler while you migrate from Legacy to V2.

## Superwall webhook limitations and pain points

### Two formats during migration

**The Problem:** A handler written for one format crashes on the other.

**Why It Happens:** Legacy webhooks keep their camelCase body and are never converted. V2 uses snake_case, different IDs and a separate `refund` event.

**Workarounds:**

* Check `superwall_version` first. Create a V2 webhook alongside the Legacy one, compare both streams, then delete the Legacy webhook.

**How Hookdeck Can Help:** Send each webhook to its own Hookdeck source and route them to separate handlers through [connections](/docs/connections).

### Rotation breaks verification immediately

**The Problem:** Deliveries fail as soon as someone rotates the secret.

**Why It Happens:** **Rotate secret** takes effect at once, with no overlap period.

**Workarounds:**

* Rotate only when you can update the receiver straight away, and expect retries for anything rejected in between.

**How Hookdeck Can Help:** Only the Hookdeck source needs the new secret, which is a single quick change.

### Events can be held or arrive out of order

**The Problem:** Events arrive late, out of order, or without an owner.

**Why It Happens:** Delivery is at least once and unordered, and Superwall holds an event for up to 48 hours while it tries to identify the owner.

**Workarounds:**

* Order by `occurred_at` and `subscription.sequence`, and match ownerless events on the original transaction fields.

**How Hookdeck Can Help:** [Deduplication](/docs/deduplication) on `id` removes repeats; ordering logic stays with you.

### Paused webhooks lose events

**The Problem:** Events that happened while a webhook was paused never arrive.

**Why It Happens:** Superwall doesn't deliver events that occur while a webhook is paused, and its retry schedule isn't published.

**Workarounds:**

* Avoid pausing. For deliveries that failed rather than being skipped, `GET /v2/projects/{project_id}/events` lists sent events and `POST /v2/projects/{project_id}/events/{event_id}/retry` resends one to a webhook.

**How Hookdeck Can Help:** Pause the [connection](/docs/connections) in Hookdeck instead. Events keep arriving and wait there until you resume.

## Best practices

### Grant access from `subscription.is_active`

Don't infer access from event names. `cancellation` leaves a subscription active until expiry, for example. Use the latest `subscription` snapshot.

### Record revenue from `event_data`

Process `event_data` from every event, even an older one, for accounting. Refunds carry negative `price` and `proceeds`.

### Deduplicate on `id`

The V2 `id` is stable across redelivery. Our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency) covers durable storage.

### Separate sandbox traffic

Check `event_data.environment` and keep `SANDBOX` purchases out of production revenue.

## Conclusion

Superwall's webhooks bring subscription events from every store into one stream. Use V2 for new work, check `superwall_version` while Legacy webhooks remain, grant access from the newest `subscription` snapshot, record revenue from `event_data`, and treat secret rotation as an instant cut-over.

[Hookdeck Event Gateway](https://hookdeck.com) lets you pause delivery without losing events and replay them during a migration. [Create a free account](https://dashboard.hookdeck.com/signup) and point a Superwall webhook at a Hookdeck source.