Webhooks V2
Receive subscription lifecycle events with the subscriber's current state, identity and attributes in every payload.
Webhooks V2 is the current Superwall webhook format. Each event still carries the facts of the store event that triggered it, the same facts as legacy webhooks. It also carries the subscriber's current subscription state, their aliases and their user attributes. You don't need to call back into Superwall to find out who the user is or whether they're still subscribed.
Every webhook you create in the dashboard uses the V2 format. Webhooks you created before V2 are labelled Legacy. They keep working and stay editable, and Superwall never converts them to V2.
Webhooks V2 sends a different payload from legacy webhooks. To migrate, create a V2 webhook next to your legacy one, check your receiver against both, and then delete the legacy webhook. See Migrating from legacy webhooks.
Create a webhook
Webhooks require revenue tracking. Until it's set up, the dashboard shows Needs revenue tracking on the Webhooks integration.
- 1
Open Webhooks
In your app, go to Integrations and select Webhooks under Webhooks and tools.
- 2
Create the webhook
Click Create webhook and fill in:
- Webhook name: a name that identifies this webhook in the dashboard.
- Webhook URL: the
https://URL Superwall should send events to. The URL can't contain credentials. - Events to listen for: the event types to send to this URL. All events are selected by default.
- Custom headers (optional): headers Superwall adds to every request, such as
X-API-Key. AnAuthorizationheader is removed before delivery, so put credentials in a different header.
- 3
Copy the signing secret
After you create the webhook, the dashboard shows its signing secret once. Copy it and store it on your server, for example as
SUPERWALL_WEBHOOK_SECRET. Use it to verify that each request comes from Superwall.If you lose the secret, open the webhook and click Rotate secret. The new secret takes effect immediately, so your server rejects Superwall's requests until you update it.
Manage webhooks
Open a webhook to see its status, success rate, the event types it listens for and its Recent activity: each delivery attempt with its status, response code and duration. Filter the attempts by Success, Pending, Failed or Sending.
The Activity card on the Webhooks page lists every message Superwall sent for your app, with its event type, environment and store. If your app has both V2 and legacy webhooks, switch between New webhooks and Legacy webhooks to see each log.
From the webhook's menu you can:
- Pause it. Superwall stops sending events to it. Events that occur while it's paused are not delivered later.
- Resume it.
- Delete it. This can't be undone. To stop deliveries but keep the configuration, pause it instead.
Payload
Every request is a POST with a JSON body. Fields are snake_case, and every timestamp is an integer in milliseconds since the Unix epoch.
{
"id": "evt_ea9c41ca861013812aa864e1807d5675",
"api_version": "2026-09-22",
"type": "renewal",
"occurred_at": 1750000000000,
"project_id": "8712",
"app_user_id": "user_1",
"ordering_key": "8712/user_1",
"is_family_share": false,
"pointer_mismatch": false,
"event_data": {
"id": "evt_abc:renewal",
"name": "renewal",
"application_id": "1234",
"store": "APP_STORE",
"environment": "PRODUCTION",
"transaction_id": "txn_1",
"original_transaction_id": "otxn_1",
"app_transaction_id": "705000000000001",
"original_app_user_id": "user_1",
"product_id": "com.example.pro.monthly",
"product_type": "autorenewable",
"new_product_id": null,
"period_type": "NORMAL",
"offer_code": null,
"is_trial_conversion": false,
"is_refund": false,
"is_family_share": false,
"is_small_business": false,
"price": 9.99,
"proceeds": 8.49,
"price_in_purchased_currency": 9.99,
"currency_code": "USD",
"country_code": "US",
"exchange_rate": 1.0,
"tax_percentage": 0.0,
"commission_percentage": 0.15,
"takehome_percentage": 0.85,
"purchased_at": 1750000000000,
"expiration_at": 1752592000000,
"cancel_reason": null,
"expiration_reason": null,
"bundle_id": "com.example.app",
"platform_token": null,
"checkout_context": null,
"ts": 1750000000000
},
"subscription": {
"subscription_id": "otxn_1",
"transaction_id": "txn_1",
"sequence": 1750000000000040,
"store": "app-store",
"environment": "live",
"product_id": "com.example.pro.monthly",
"product_type": "autorenewable",
"state": "active",
"is_active": true,
"is_lifetime": false,
"will_renew": true,
"offer_type": null,
"starts_at": 1750000000000,
"renewed_at": 1750000000000,
"expires_at": 1752592000000,
"original_purchase_date": null,
"amount_micros": 9990000,
"currency": "USD"
},
"previous": null,
"subscriber": {
"app_user_id": "user_1",
"original_app_user_id": "user_1",
"aliases": ["user_1"],
"first_seen_at": 1750000000000,
"identity_status": "resolved",
"attributes": {
"email": {
"value": "user@example.com",
"updated_at": 1749999999000
}
}
}
}The payload has four parts:
- Envelope (top-level fields): what happened, when and to whom.
event_data: the facts of the store event that triggered the webhook. Use it for accounting.subscriptionandprevious: the subscription's state now and before.subscriber: who owns the subscription, with their aliases and attributes.
Envelope
| Field | Type | Description |
|---|---|---|
id | string | Unique event ID, evt_ followed by 32 hex characters. It stays the same when an event is redelivered, so use it to deduplicate. |
api_version | string | Payload version. Currently 2026-09-22. |
type | string | The event type in lowercase, such as renewal. |
occurred_at | number | When the store event happened. Use it to order events. |
project_id | string | Your Superwall project ID. |
app_user_id | string or null | The user who currently owns the subscription. null when Superwall doesn't know the owner (see Events without a known user). |
ordering_key | string | Groups events that belong to the same user, or to the same subscription when there's no known user. |
is_family_share | boolean | Whether the subscription is shared through Family Sharing (App Store only). |
pointer_mismatch | boolean | Diagnostic flag. You can ignore it. |
event_data | object or null | The triggering event's facts. null only for state-only events replayed from before event_data existed. |
subscription | object or null | The current subscription snapshot, or null when it isn't available. |
previous | object or null | The subscription's earlier state, when Superwall observed one. |
subscriber | object or null | The subscription's owner, or null when the owner is unknown. |
event_data
event_data describes the store event that triggered the webhook. It doesn't change after the event, so use it for payment processing, revenue and accounting.
The fields match legacy webhooks' data object, renamed to snake_case. For example, transactionId is now transaction_id. Unknown values are null, never 0.
| Field | Type | Description |
|---|---|---|
id | string | The store event's ID. The same value as data.id in legacy webhooks. |
name | string | The store event name, such as renewal or cancellation. |
application_id | string | The Superwall application the event belongs to. For Stripe, this is the app that billed the user. |
store | string | APP_STORE, MAC_APP_STORE, PLAY_STORE, STRIPE or CUSTOM. |
environment | string | PRODUCTION or SANDBOX. |
transaction_id | string | The transaction that triggered the event. Store this one, not subscription.transaction_id. |
original_transaction_id | string | The subscription's original transaction ID. |
app_transaction_id | string or null | Apple's appTransactionId. App Store only. |
original_app_user_id | string or null | The first app user ID seen with this subscription, as recorded on the event. |
product_id | string | The product identifier. |
product_type | string or null | autorenewable, nonRenewable, consumable or nonConsumable. |
new_product_id | string or null | The new product, for product_change events. |
period_type | string | NORMAL, TRIAL, INTRO, PREPAID or PROMOTIONAL. |
offer_code | string or null | The offer code used, if any. |
is_trial_conversion | boolean or null | Whether this renewal converted a trial to a paid subscription. |
is_refund | boolean or null | Whether the event is a refund. |
is_family_share | boolean or null | Whether the purchase is shared through Family Sharing. |
is_small_business | boolean or null | Whether the store's small business commission rate applies. |
price | number or null | Price in USD. Negative for refunds. |
proceeds | number or null | Proceeds in USD after store commission and taxes. Negative for refunds. |
price_in_purchased_currency | number or null | Price in currency_code. Negative for refunds. |
currency_code | string or null | ISO currency code of price_in_purchased_currency. |
country_code | string or null | ISO country code. |
exchange_rate | number or null | Rate used to convert to USD. |
tax_percentage | number or null | Tax rate applied. |
commission_percentage | number or null | Store commission rate. |
takehome_percentage | number or null | Your share after commission. |
purchased_at | number | When the transaction was purchased. |
expiration_at | number or null | When the transaction expires. |
cancel_reason | string or null | UNSUBSCRIBE, BILLING_ERROR, DEVELOPER_INITIATED, PRICE_INCREASE, CUSTOMER_SUPPORT or UNKNOWN. |
expiration_reason | string or null | Why the subscription expired, as reported by the store. |
bundle_id | string or null | The app's bundle identifier. |
platform_token | string or null | The store's purchase token, where the store provides one. |
checkout_context | object or null | Stripe checkout details. Its keys stay camelCase: checkoutContextId, applicationId, initiatingApplicationId and originalAppUserId. initiatingApplicationId is the app the checkout started from. |
ts | number | When the store event happened. |
subscription
subscription is the subscription's state when Superwall sent the webhook. It can describe a newer transaction than event_data, for example when a renewal arrives after a refund was already processed.
subscription is null when the owner is unknown or the state isn't available.
| Field | Type | Description |
|---|---|---|
subscription_id | string | The subscription's ID: its original transaction ID. |
transaction_id | string | The latest transaction in the subscription. |
sequence | number | Orders snapshots of the same subscription. A higher value is newer. |
store | string | app-store, play-store, stripe or custom:<application_id>. |
environment | string | live or test. |
product_id | string | The current product. |
product_type | string | autorenewable, nonRenewable, consumable or nonConsumable. |
state | string | active, inGracePeriod, inBillingRetryPeriod, expired or revoked. |
is_active | boolean | Whether the subscription currently grants access. |
is_lifetime | boolean | Whether it's a lifetime purchase. |
will_renew | boolean | Whether it's set to renew. |
offer_type | string or null | trial, promotional or code. |
starts_at | number | When the subscription started. |
renewed_at | number or null | When it last renewed. |
expires_at | number or null | When the current period ends. |
original_purchase_date | number or null | When it was first purchased. |
amount_micros | number or null | Price in currency multiplied by 1,000,000. Only set when the snapshot's transaction is the event's transaction. |
currency | string or null | ISO currency code of amount_micros. |
previous
previous is the subscription's state before this event, when Superwall observed an older one. It's never the same as or newer than subscription.
| Field | Type | Description |
|---|---|---|
state | string | The earlier state. |
is_active | boolean | Whether it granted access then. |
will_renew | boolean | Whether it was set to renew then. |
Compare previous with subscription to spot transitions, such as is_active changing from true to false.
subscriber
subscriber is the user who owns the subscription, merged across all of their aliases.
| Field | Type | Description |
|---|---|---|
app_user_id | string | The owner's current app user ID. |
original_app_user_id | string | The first app user ID in the owner's identity group. Never null, and can equal app_user_id. |
aliases | string[] | Every app user ID and alias linked to the owner. |
first_seen_at | number or null | When Superwall first saw the user. |
identity_status | string | resolved, or missing when Superwall knows the owner's ID but has no other data for them. subscriber is null, not missing, when the owner itself is unknown. |
attributes | object | The user attributes you set from the SDK. Each key maps to { "value": ..., "updated_at": ... }. When aliases disagree, the most recent value wins. |
Event types
The payload's type field is lowercase. The event types you select in the dashboard and pass to the API are uppercase.
type | Event type | Sent when |
|---|---|---|
initial_purchase | INITIAL_PURCHASE | A user starts a subscription or trial. |
renewal | RENEWAL | A subscription renews, including a trial or intro offer converting to paid. |
non_renewing_purchase | NON_RENEWING_PURCHASE | A user makes a one-time purchase. |
product_change | PRODUCT_CHANGE | A user switches to a different product. |
cancellation | CANCELLATION | A user turns off auto-renew. The subscription stays active until it expires. |
uncancellation | UNCANCELLATION | A user turns auto-renew back on. |
refund | REFUND | A purchase is refunded. |
billing_issue | BILLING_ISSUE | The store can't charge the user. |
subscription_paused | SUBSCRIPTION_PAUSED | A user pauses their subscription (Google Play only). |
subscription_extended | SUBSCRIPTION_EXTENDED | The store extends a subscription's expiration date. |
expiration | EXPIRATION | A subscription expires. |
transferred | TRANSFERRED | Reserved for ownership changes. Not sent yet. |
Superwall doesn't send test events to V2 webhooks. To test a webhook, make a sandbox purchase.
Delivery
Verify each request
Superwall signs every request with your webhook's signing secret. Requests include the svix-id, svix-timestamp and svix-signature headers. See Verify webhook requests for code samples.
Webhooks created in the dashboard also send a superwall_version header: 2 for V2 webhooks and 1 for legacy ones. A receiver that handles both formats can use it to tell them apart.
Respond quickly
Return a 2xx status as soon as you've stored the request, and process it afterwards. Superwall treats any other status, or a timeout, as a failure and retries the request with exponential backoff.
Handle duplicates and out-of-order events
Delivery is at least once and unordered:
- Deduplicate on
id. A retried or redelivered event keeps the sameid. - Order events by
occurred_at, not by when they arrive. - Update stored subscription state with
subscription.sequence. Keep the snapshot with the highestsequencefor eachsubscription_id. An older event can still matter for accounting. Process itsevent_dataeven when its snapshot is older than the one you've stored.
Events without a known user
Superwall sometimes receives a store event before it knows which user owns the subscription, for example when the store notification arrives before your app reports the purchase. Superwall holds these events for up to 48 hours waiting for the owner:
- If the owner becomes known during the hold, Superwall sends the event once, with
app_user_idandsubscriberfilled in. - Otherwise, Superwall sends it once after the hold, with
app_user_id,subscriberandsubscriptionset tonull. It isn't sent again if the owner becomes known later.
An event without a known owner never grants access. Use event_data.original_app_user_id and event_data.original_transaction_id to match it to your own records.
Common use cases
Use event_data for the facts of the event and subscription for the current state.
const event = JSON.parse(body);
switch (event.type) {
case "initial_purchase":
if (event.event_data?.period_type === "TRIAL") {
// A trial started
}
break;
case "renewal":
if (event.event_data?.is_trial_conversion) {
// A trial converted to paid
}
break;
case "refund":
// event_data.price and event_data.proceeds are negative
break;
case "expiration":
// Access ended
break;
}
// Grant or revoke access from the current state, whatever the event type
if (event.subscriber && event.subscription) {
setAccess(event.subscriber.app_user_id, event.subscription.is_active);
}To sum revenue, add up event_data.proceeds across every event. Refunds are negative, so the total is your net revenue. Skip events where proceeds is null.
Migrating from legacy webhooks
| Topic | Legacy | V2 |
|---|---|---|
| Event facts | data, camelCase | event_data, snake_case |
| Event ID | data.id | id (evt_...). event_data.id holds the legacy data.id value. |
| Event time | timestamp (when the webhook was created) | occurred_at (when the store event happened) |
| Project ID | projectId, a number | project_id, a string |
| Application ID | applicationId, a number | event_data.application_id, a string |
| Refunds | cancellation with isRefund: true | A separate refund event |
| User attributes | data.userAttributes: { "key": value } | subscriber.attributes: { "key": { "value": value, "updated_at": ... } }, merged across aliases |
| User ID | data.originalAppUserId | app_user_id and subscriber, plus event_data.original_app_user_id |
| Subscription state | Not included | subscription and previous |
| Device info | data.deviceInfo | Not included |
| Test events | Sent | Not sent |
| Version header | superwall_version: 1 (dashboard-created webhooks) | superwall_version: 2 (dashboard-created webhooks) |
To migrate:
- Create a V2 webhook pointing at a new URL, or at your existing URL if your receiver can tell the formats apart by the
superwall_versionheader. - Update your receiver to read the V2 payload, and deduplicate on
id. - Compare the two streams for a while, for example by checking that each legacy
data.idarrives as a V2event_data.id, and that your revenue totals match. - Delete the legacy webhook.
Manage webhooks with the API
You can manage V2 webhooks with the Superwall API, using an API key with the webhooks:read and webhooks:write scopes.
Pass format=superwall_v2 on every webhook and event request. Without it, 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/superwall/webhooks",
"description": "Production receiver",
"filter_types": ["INITIAL_PURCHASE", "RENEWAL", "REFUND", "EXPIRATION"]
}'The response includes the webhook's secret. It's only returned here and when you rotate the secret. Omit filter_types to receive every event type, including types added later.
| Method | Path | Description |
|---|---|---|
GET | /v2/projects/{project_id}/webhook_endpoints | List webhooks. |
POST | /v2/projects/{project_id}/webhook_endpoints | Create a webhook. |
GET | /v2/projects/{project_id}/webhook_endpoints/{endpoint_id} | Get a webhook. |
PATCH | /v2/projects/{project_id}/webhook_endpoints/{endpoint_id} | Update a webhook. Set disabled to pause it. |
DELETE | /v2/projects/{project_id}/webhook_endpoints/{endpoint_id} | Delete a webhook. |
POST | /v2/projects/{project_id}/webhook_endpoints/{endpoint_id}/rotate_secret | Rotate the signing secret. |
GET | /v2/projects/{project_id}/webhook_endpoints/{endpoint_id}/attempts | List delivery attempts to a webhook. |
GET | /v2/projects/{project_id}/webhook_endpoints/{endpoint_id}/stats | Count successful, failed and pending attempts. |
GET | /v2/projects/{project_id}/events | List sent events. Filter with event_types. |
GET | /v2/projects/{project_id}/events/{event_id} | Get an event and its payload. |
GET | /v2/projects/{project_id}/events/{event_id}/attempts | List an event's delivery attempts. |
POST | /v2/projects/{project_id}/events/{event_id}/retry | Resend an event to one webhook. The body is { "endpoint_id": "..." }. |
How is this guide?