Gareth Wilson Gareth Wilson

Guide to Superwall Webhooks: Features and Best Practices

Published


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

FeatureDetails
SignatureStandard Webhooks HMAC-SHA256
Signature headersvix-signature
BodyJSON
Event routingBody field type
Delivery platformSvix
PrerequisiteRevenue tracking set up
ConfigurationDashboard or API (format=superwall_v2)
FormatsV2 and Legacy, told apart by superwall_version
Secretwhsec_ plus base64, shown once; rotation is immediate
Custom headersOptional; Authorization is stripped
RetriesExponential backoff after non-2xx or timeout; no schedule published
GuaranteesAt least once, unordered
Test eventsNone for V2

Events and payloads

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

EventTriggered When
initial_purchaseA user starts a subscription or trial
renewalA subscription renews, including a trial or intro offer converting to paid
non_renewing_purchaseA user makes a one-time purchase
product_changeA user switches to a different product (event_data.new_product_id)
cancellationA user turns off auto-renew; the subscription stays active until it expires
uncancellationA user turns auto-renew back on
refundA purchase is refunded; price and proceeds are negative
billing_issueThe store can't charge the user
subscription_pausedA user pauses their subscription (Google Play only)
subscription_extendedThe store extends a subscription's expiration date
expirationA subscription expires
transferredReserved 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 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:

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

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


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.