Guide to Superwall Webhooks: Features and Best Practices
Superwall 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 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_) |
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, andoccurred_atfor ordering.event_data: the facts of the store event, for revenue. Useful fields includestore(APP_STORE,MAC_APP_STORE,PLAY_STORE,STRIPE,CUSTOM),environment(PRODUCTIONorSANDBOX),period_type(NORMAL,TRIAL,INTRO,PREPAID,PROMOTIONAL),is_trial_conversion,cancel_reasonandexpiration_reason.event_data.namerepeats the store's event name.subscriptionandprevious: the current and earlier subscription state.subscription.is_activedecides access, andsubscription.sequenceorders 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_ and event_.
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 first; until then the Webhooks integration shows Needs revenue tracking.
In the dashboard:
- Open your app, go to Integrations and choose Webhooks under Webhooks and tools.
- Click Create webhook and enter a name and an HTTPS URL, such as
https://example.com/webhooks/superwall. The URL can't contain credentials. - Choose events. All are selected by default;
initial_purchase,renewal,cancellation,refund,billing_issueandexpirationcover most needs. - Optionally add custom headers, such as
X-API-Key. Superwall strips anyAuthorizationheader, so use another name for credentials. - 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:
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 can forward those deliveries to your machine:
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:
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:
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 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_versionfirst. 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.
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_atandsubscription.sequence, and match ownerless events on the original transaction fields.
How Hookdeck Can Help: 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_lists sent events andid}/events POST /v2/projects/{project_resends one to a webhook.id}/events/{event_ id}/retry
How Hookdeck Can Help: Pause the connection 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 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 lets you pause delivery without losing events and replay them during a migration. Create a free account and point a Superwall webhook at a Hookdeck source.