Agent skill
Superwall Webhooks Skill
Receive and verify Superwall webhooks (subscription and paywall infrastructure for iOS, Android and Web, delivered via Svix). Use when setting up Superwall webhook handlers, debugging svix-signature verification, or handling subscription events like initial_purchase, renewal, cancellation, refund, billing_issue, and expiration.
Install this skill
npx skills add hookdeck/webhook-skills --skill superwall-webhooks
Superwall (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_. 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,cancellationorrefundevents? - 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.
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
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/, examples/nextjs/, 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, 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_) |
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 for the payload shape and field details.
Environment Variables
# Signing secret of the webhook (Svix format, starts with whsec_)
SUPERWALL_WEBHOOK_SECRET=whsec_xxxxx
Local Development
# 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 - Superwall webhook concepts, events, V2 and legacy payload shapes, delivery
- references/setup.md - Create a webhook, get the secret, test with a sandbox purchase
- references/verification.md - Svix verification details, header fallback, manual verifier, timestamp window