Skip to main content
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. 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 in the reference.

Walkthrough

1

Set a stable user id

Call 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.
import Encore from '@encorekit/web-sdk';

Encore.identify('user-123');
2

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.
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;
}
3

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.
// 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 });
});
4

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 — import it rather than reimplementing the HMAC. It signs the composite <timestamp>.POST./webhooks/encore.<raw_body> string and returns the auth headers.
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);
});
5

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.
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);
});
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.

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 — the full contract: headers, signing helper, payload schema, responses, idempotency.
  • identify() — set a stable user id for a robust cross-device join.