Webhooks

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

    Open Webhooks

    In your app, go to Integrations and select Webhooks under Webhooks and tools.

  2. 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. An Authorization header is removed before delivery, so put credentials in a different header.
  3. 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.
  • subscription and previous: the subscription's state now and before.
  • subscriber: who owns the subscription, with their aliases and attributes.

Envelope

FieldTypeDescription
idstringUnique event ID, evt_ followed by 32 hex characters. It stays the same when an event is redelivered, so use it to deduplicate.
api_versionstringPayload version. Currently 2026-09-22.
typestringThe event type in lowercase, such as renewal.
occurred_atnumberWhen the store event happened. Use it to order events.
project_idstringYour Superwall project ID.
app_user_idstring or nullThe user who currently owns the subscription. null when Superwall doesn't know the owner (see Events without a known user).
ordering_keystringGroups events that belong to the same user, or to the same subscription when there's no known user.
is_family_sharebooleanWhether the subscription is shared through Family Sharing (App Store only).
pointer_mismatchbooleanDiagnostic flag. You can ignore it.
event_dataobject or nullThe triggering event's facts. null only for state-only events replayed from before event_data existed.
subscriptionobject or nullThe current subscription snapshot, or null when it isn't available.
previousobject or nullThe subscription's earlier state, when Superwall observed one.
subscriberobject or nullThe 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.

FieldTypeDescription
idstringThe store event's ID. The same value as data.id in legacy webhooks.
namestringThe store event name, such as renewal or cancellation.
application_idstringThe Superwall application the event belongs to. For Stripe, this is the app that billed the user.
storestringAPP_STORE, MAC_APP_STORE, PLAY_STORE, STRIPE or CUSTOM.
environmentstringPRODUCTION or SANDBOX.
transaction_idstringThe transaction that triggered the event. Store this one, not subscription.transaction_id.
original_transaction_idstringThe subscription's original transaction ID.
app_transaction_idstring or nullApple's appTransactionId. App Store only.
original_app_user_idstring or nullThe first app user ID seen with this subscription, as recorded on the event.
product_idstringThe product identifier.
product_typestring or nullautorenewable, nonRenewable, consumable or nonConsumable.
new_product_idstring or nullThe new product, for product_change events.
period_typestringNORMAL, TRIAL, INTRO, PREPAID or PROMOTIONAL.
offer_codestring or nullThe offer code used, if any.
is_trial_conversionboolean or nullWhether this renewal converted a trial to a paid subscription.
is_refundboolean or nullWhether the event is a refund.
is_family_shareboolean or nullWhether the purchase is shared through Family Sharing.
is_small_businessboolean or nullWhether the store's small business commission rate applies.
pricenumber or nullPrice in USD. Negative for refunds.
proceedsnumber or nullProceeds in USD after store commission and taxes. Negative for refunds.
price_in_purchased_currencynumber or nullPrice in currency_code. Negative for refunds.
currency_codestring or nullISO currency code of price_in_purchased_currency.
country_codestring or nullISO country code.
exchange_ratenumber or nullRate used to convert to USD.
tax_percentagenumber or nullTax rate applied.
commission_percentagenumber or nullStore commission rate.
takehome_percentagenumber or nullYour share after commission.
purchased_atnumberWhen the transaction was purchased.
expiration_atnumber or nullWhen the transaction expires.
cancel_reasonstring or nullUNSUBSCRIBE, BILLING_ERROR, DEVELOPER_INITIATED, PRICE_INCREASE, CUSTOMER_SUPPORT or UNKNOWN.
expiration_reasonstring or nullWhy the subscription expired, as reported by the store.
bundle_idstring or nullThe app's bundle identifier.
platform_tokenstring or nullThe store's purchase token, where the store provides one.
checkout_contextobject or nullStripe checkout details. Its keys stay camelCase: checkoutContextId, applicationId, initiatingApplicationId and originalAppUserId. initiatingApplicationId is the app the checkout started from.
tsnumberWhen 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.

FieldTypeDescription
subscription_idstringThe subscription's ID: its original transaction ID.
transaction_idstringThe latest transaction in the subscription.
sequencenumberOrders snapshots of the same subscription. A higher value is newer.
storestringapp-store, play-store, stripe or custom:<application_id>.
environmentstringlive or test.
product_idstringThe current product.
product_typestringautorenewable, nonRenewable, consumable or nonConsumable.
statestringactive, inGracePeriod, inBillingRetryPeriod, expired or revoked.
is_activebooleanWhether the subscription currently grants access.
is_lifetimebooleanWhether it's a lifetime purchase.
will_renewbooleanWhether it's set to renew.
offer_typestring or nulltrial, promotional or code.
starts_atnumberWhen the subscription started.
renewed_atnumber or nullWhen it last renewed.
expires_atnumber or nullWhen the current period ends.
original_purchase_datenumber or nullWhen it was first purchased.
amount_microsnumber or nullPrice in currency multiplied by 1,000,000. Only set when the snapshot's transaction is the event's transaction.
currencystring or nullISO 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.

FieldTypeDescription
statestringThe earlier state.
is_activebooleanWhether it granted access then.
will_renewbooleanWhether 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.

FieldTypeDescription
app_user_idstringThe owner's current app user ID.
original_app_user_idstringThe first app user ID in the owner's identity group. Never null, and can equal app_user_id.
aliasesstring[]Every app user ID and alias linked to the owner.
first_seen_atnumber or nullWhen Superwall first saw the user.
identity_statusstringresolved, 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.
attributesobjectThe 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.

typeEvent typeSent when
initial_purchaseINITIAL_PURCHASEA user starts a subscription or trial.
renewalRENEWALA subscription renews, including a trial or intro offer converting to paid.
non_renewing_purchaseNON_RENEWING_PURCHASEA user makes a one-time purchase.
product_changePRODUCT_CHANGEA user switches to a different product.
cancellationCANCELLATIONA user turns off auto-renew. The subscription stays active until it expires.
uncancellationUNCANCELLATIONA user turns auto-renew back on.
refundREFUNDA purchase is refunded.
billing_issueBILLING_ISSUEThe store can't charge the user.
subscription_pausedSUBSCRIPTION_PAUSEDA user pauses their subscription (Google Play only).
subscription_extendedSUBSCRIPTION_EXTENDEDThe store extends a subscription's expiration date.
expirationEXPIRATIONA subscription expires.
transferredTRANSFERREDReserved 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 same id.
  • Order events by occurred_at, not by when they arrive.
  • Update stored subscription state with subscription.sequence. Keep the snapshot with the highest sequence for each subscription_id. An older event can still matter for accounting. Process its event_data even 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_id and subscriber filled in.
  • Otherwise, Superwall sends it once after the hold, with app_user_id, subscriber and subscription set to null. 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

TopicLegacyV2
Event factsdata, camelCaseevent_data, snake_case
Event IDdata.idid (evt_...). event_data.id holds the legacy data.id value.
Event timetimestamp (when the webhook was created)occurred_at (when the store event happened)
Project IDprojectId, a numberproject_id, a string
Application IDapplicationId, a numberevent_data.application_id, a string
Refundscancellation with isRefund: trueA separate refund event
User attributesdata.userAttributes: { "key": value }subscriber.attributes: { "key": { "value": value, "updated_at": ... } }, merged across aliases
User IDdata.originalAppUserIdapp_user_id and subscriber, plus event_data.original_app_user_id
Subscription stateNot includedsubscription and previous
Device infodata.deviceInfoNot included
Test eventsSentNot sent
Version headersuperwall_version: 1 (dashboard-created webhooks)superwall_version: 2 (dashboard-created webhooks)

To migrate:

  1. 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_version header.
  2. Update your receiver to read the V2 payload, and deduplicate on id.
  3. Compare the two streams for a while, for example by checking that each legacy data.id arrives as a V2 event_data.id, and that your revenue totals match.
  4. 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.

MethodPathDescription
GET/v2/projects/{project_id}/webhook_endpointsList webhooks.
POST/v2/projects/{project_id}/webhook_endpointsCreate 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_secretRotate the signing secret.
GET/v2/projects/{project_id}/webhook_endpoints/{endpoint_id}/attemptsList delivery attempts to a webhook.
GET/v2/projects/{project_id}/webhook_endpoints/{endpoint_id}/statsCount successful, failed and pending attempts.
GET/v2/projects/{project_id}/eventsList 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}/attemptsList an event's delivery attempts.
POST/v2/projects/{project_id}/events/{event_id}/retryResend an event to one webhook. The body is { "endpoint_id": "..." }.

How is this guide?

On this page