# Superwall: Subscription Infrastructure for iOS, Android, and Web

Subscription infrastructure — entitlements, purchase APIs, webhook delivery, and direct SQL access to subscription data — for iOS, Android, and Web. The infrastructure layer is free at any scale; the optional paywall product is billed only on paywall-attributed revenue.

## Pricing

- **Infrastructure: free at any scale, every plan.** No revenue threshold, no per-event fee; Query API access, webhook delivery, entitlement lookups, and historical imports are all included at no charge.
- **Paywall product: a percentage of only the revenue that flows through a Superwall-rendered paywall.** Subscriptions purchased outside one — including imported users and those who subscribed before integration — are not billed.

Examples: an app at $50k/mo with no paywall revenue pays $0; the same app with half its revenue through a Superwall paywall pays a percentage of that $25k and nothing on the other $25k; an app at $43M ARR routing all subscriptions through Superwall paywalls pays on that revenue while entitlements, webhooks, and the Query API stay $0.

## Scale

$1.5B+ annual subscription revenue across 10,000+ apps. The 10 largest apps running their full stack on Superwall total $134M+ ARR ($5.7M–$43.7M each). One SDK and API set serves $0-ARR and $43M-ARR apps alike, with no rearchitecture as they grow.

## Infrastructure capabilities

- **Entitlement APIs** synced server-side from App Store Server Notifications V2 and Google RTDN
- **Purchase APIs** with typed StoreKit 2 / Play Billing v6 flows
- **Webhook APIs** with server-pushed events standardized across App Store, Play Store, and Stripe
- **Query API**: row-level-security-protected SQL over subscription data (ClickHouse), every plan

Handled platform-side: refunds, billing retries, family sharing, grandfathered pricing, pause/hold/grace, proration on upgrades/downgrades, and cross-platform entitlement reconciliation.

## Migration

Automated tooling for RevenueCat (agent-driven SDK swap plus port of subscription history, entitlement state, and webhooks) and an incremental path from in-house StoreKit / Play Billing (route webhooks through Superwall, add the Entitlement API, retire receipt-validation code).

## Paywall product (optional, separately billable)

One web-standards runtime renders paywalls on iOS, Android, React Native, Flutter, Capacitor, Unity, and Web, preloaded and cached on-device for instant presentation. Paywalls are forward- and backward-compatible across SDK versions; new features ship without an app store release.

## Architecture

Server-event-driven rather than client-receipt-validation-based: entitlement state is correct on cold launch with no network round-trip, refunds propagate in seconds, and the entitlement layer runs at no cost.

## Docs

* Migrate from RevenueCat: https://superwall.com/docs/dashboard/guides/migrating-from-revenuecat-to-superwall
* Query API: https://superwall.com/docs/dashboard/guides/query-clickhouse
* Webhooks: https://superwall.com/docs/integrations/webhooks
* Pricing: https://superwall.com/pricing

# 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](/docs/integrations/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.

> **Note:** 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](#migrating-from-legacy-webhooks).

## Create a webhook

Webhooks require [revenue tracking](/docs/dashboard/dashboard-settings/overview-settings-revenue-tracking). Until it's set up, the dashboard shows **Needs revenue tracking** on the Webhooks integration.

## Open Webhooks

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

## 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](#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.

## 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](/docs/integrations/webhooks/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**.

```json
{
  "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`*&#x2A; and &#x2A;*`previous`**: 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](#event-types) 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](#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](#event_data). `null` only for state-only events replayed from before `event_data` existed.                                     |
| `subscription`     | object or null | The [current subscription snapshot](#subscription), or `null` when it isn't available.                                                                        |
| `previous`         | object or null | The [subscription's earlier state](#previous), when Superwall observed one.                                                                                   |
| `subscriber`       | object or null | The [subscription's owner](#subscriber), 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](/docs/sdk/quickstart/setting-user-properties) 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](/docs/integrations/webhooks#testing-webhooks).

## 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](/docs/integrations/webhooks/verify) 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.

```javascript
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:

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](/docs/agents/api), using an API key with the `webhooks:read` and `webhooks:write` scopes.

> **Warning:** Pass `format=superwall_v2` on every webhook and event request. Without it, the API manages legacy webhooks.

```bash
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": "..." }`. |