> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encorekit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Subscription Webhook

> End-to-end Stripe walkthrough: carry the Encore app account id through client_reference_id and forward every subscription lifecycle event into Encore for incrementality measurement.

If you run your own billing, Encore can still measure the incremental impact of your web
offers — as long as your subscription lifecycle events flow back into Encore and join to the
exposures it recorded. This guide walks through that integration end to end with **Stripe**.

For the endpoint contract — headers, the signing helper, the full payload schema, and
responses — see the [Webhook Ingestion reference](/publishers/web/sdk-reference/webhook-ingestion).
This guide is the tutorial; the reference is the contract.

## When to use this

Follow this guide if you bill subscriptions through **Stripe** (the approach generalizes to
any self-managed biller) and want those events in Encore. If you use RevenueCat, Superwall,
or store server notifications, point those at their dedicated receivers instead.

## How it fits together

Encore joins a subscription back to the user it showed an offer to using two values:

* The **app account id** — read on the client with `Encore.getAppAccountId()`.
* Your biller's **subscription id** — sent as `subscription.original_transaction_id`.

You carry the app account id through Stripe via `client_reference_id`, persist it keyed by
the subscription id, then send **both values on every event** you forward. Encore (re)writes
the link on every event, so `user.app_account_id` is required each time — see
[How the join works](/publishers/web/sdk-reference/webhook-ingestion#how-the-join-works) in
the reference.

## Walkthrough

<Steps>
  <Step title="Set a stable user id">
    Call [`identify()`](/publishers/web/sdk-reference/identify) once you know who the user is.
    This makes the app account id your own stable id, so the same person resolves consistently
    across devices and sessions.

    ```javascript theme={null}
    import Encore from '@encorekit/web-sdk';

    Encore.identify('user-123');
    ```
  </Step>

  <Step title="Read the app account id and start checkout">
    At checkout, read the app account id and pass it to your server so it can attach it to the
    Stripe Checkout Session.

    ```javascript theme={null}
    import Encore from '@encorekit/web-sdk';

    async function startCheckout() {
      const appAccountId = Encore.getAppAccountId();

      const res = await fetch('/api/create-checkout-session', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ appAccountId }),
      });

      const { url } = await res.json();
      window.location.href = url;
    }
    ```
  </Step>

  <Step title="Attach client_reference_id on the server">
    Set the app account id as `client_reference_id` so it round-trips back on the webhook. Also
    stamp it into the Subscription's `metadata` so later subscription events carry it too.

    ```javascript theme={null}
    // POST /api/create-checkout-session
    app.post('/api/create-checkout-session', async (req, res) => {
      const session = await stripe.checkout.sessions.create({
        mode: 'subscription',
        line_items: [{ price: 'price_pro_monthly', quantity: 1 }],
        success_url: 'https://example.com/success',
        cancel_url: 'https://example.com/cancel',
        client_reference_id: req.body.appAccountId, // round-trips on checkout.session.completed
        subscription_data: {
          metadata: { app_account_id: req.body.appAccountId }, // rides on subscription events
        },
      });

      res.json({ url: session.url });
    });
    ```
  </Step>

  <Step title="Forward the subscribe event to Encore">
    In your Stripe webhook handler, read the app account id from `client_reference_id`, persist
    it keyed by the subscription id (you will need it for events that don't carry it), then sign
    and POST a `did_subscribe` event.

    The `buildEncoreHeaders` helper below is the
    [shared signing helper from the reference](/publishers/web/sdk-reference/webhook-ingestion#signing-a-request) —
    import it rather than reimplementing the HMAC. It signs the composite
    `<timestamp>.POST./webhooks/encore.<raw_body>` string and returns the auth headers.

    ```javascript theme={null}
    import { buildEncoreHeaders } from './encore-signing'; // the reference's helper

    const ENCORE_URL = 'https://api.encorekit.com/encore/webhooks/encore';

    async function postToEncore(event) {
      const rawBody = JSON.stringify(event); // serialize once
      const headers = buildEncoreHeaders(rawBody, {
        publishableKey: 'pk_live_your_publishable_key',
        secretKey: process.env.ENCORE_SECRET_KEY,
      });

      await fetch(ENCORE_URL, { method: 'POST', headers, body: rawBody }); // send the same bytes
    }

    // Stripe webhook
    app.post('/webhooks/stripe', async (req, res) => {
      const event = req.body;

      if (event.type === 'checkout.session.completed') {
        const session = event.data.object;
        const appAccountId = session.client_reference_id;
        const originalTransactionId = session.subscription;

        // Persist the mapping so later events can include app_account_id.
        await saveAppAccountId(originalTransactionId, appAccountId);

        await postToEncore({
          event_id: event.id,
          event_type: 'did_subscribe',
          occurred_at: new Date(event.created * 1000).toISOString(),
          user: { app_account_id: appAccountId },
          subscription: {
            original_transaction_id: originalTransactionId,
            store: 'stripe',
          },
        });
      }

      res.sendStatus(200);
    });
    ```
  </Step>

  <Step title="Forward later lifecycle events — with app_account_id on every one">
    For renewals, cancellations, refunds, and the rest, map the Stripe event to the matching
    Encore verb. **Every event must include `user.app_account_id`** — omitting it fails
    validation and the event is dropped. Resolve it from the Subscription's `metadata` (set in
    step 3) or from the mapping you persisted in step 4.

    ```javascript theme={null}
    import { buildEncoreHeaders } from './encore-signing';

    const VERB_BY_STRIPE_EVENT = {
      'invoice.paid': 'did_renew',
      'customer.subscription.deleted': 'did_cancel',
      'charge.refunded': 'did_refund',
    };

    app.post('/webhooks/stripe', async (req, res) => {
      const event = req.body;
      const verb = VERB_BY_STRIPE_EVENT[event.type];

      if (verb) {
        const obj = event.data.object;
        const originalTransactionId = obj.subscription || obj.id; // depends on object shape

        // app_account_id is REQUIRED on every event. Prefer subscription metadata,
        // fall back to the mapping persisted at subscribe time.
        const appAccountId =
          obj.metadata?.app_account_id ||
          (await lookupAppAccountId(originalTransactionId));

        await postToEncore({
          event_id: event.id,
          event_type: verb,
          occurred_at: new Date(event.created * 1000).toISOString(),
          user: { app_account_id: appAccountId },
          subscription: { original_transaction_id: originalTransactionId },
        });
      }

      res.sendStatus(200);
    });
    ```

    <Note>
      If you cannot resolve an `app_account_id` for an event, the join cannot be made — fix the
      lookup rather than sending the event without it. An event missing `user.app_account_id` is
      acknowledged with `200 { "message": "Received (processing deferred)" }` but **dropped**. See
      [Responses](/publishers/web/sdk-reference/webhook-ingestion#responses).
    </Note>
  </Step>
</Steps>

## Event verbs

Map your billing events to Encore's canonical verbs:

`did_subscribe`, `did_renew`, `did_cancel`, `did_resubscribe`, `did_expire`,
`did_enter_grace_period`, `did_enter_billing_retry`, `did_pause`, `did_change_product`,
`did_refund`.

## Next steps

* **[Webhook Ingestion reference](/publishers/web/sdk-reference/webhook-ingestion)** — the
  full contract: headers, signing helper, payload schema, responses, idempotency.
* **[identify()](/publishers/web/sdk-reference/identify)** — set a stable user id for a
  robust cross-device join.
