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

# Webhook Ingestion

> POST /webhooks/encore — the contract for forwarding your own billing's subscription lifecycle events into Encore so they join back to web exposures for incrementality (NCL) measurement.

`POST /webhooks/encore` is the server-to-server endpoint for forwarding your subscription
lifecycle events into Encore. Encore joins each event back to the web exposure it recorded
for the same user, which is what powers NCL (incrementality) measurement.

This page is the **contract**: endpoint, authentication, signing, payload schema, responses,
and idempotency. For the end-to-end Stripe walkthrough, see the
[Subscription Webhook guide](/publishers/web/guides/subscription-webhook).

## When to use this

Use this if **you bill your own subscriptions** (for example, Stripe) and want those
events in Encore for incrementality measurement. Forward each subscription event from your
server as it happens.

If your subscriptions run through a source that already notifies us — **RevenueCat**,
**Superwall**, or **App Store / Play server notifications** — point those at their dedicated
receivers instead; do not also send them here.

## Endpoint

```
POST https://api.encorekit.com/encore/webhooks/encore
```

One request carries exactly one canonical subscription event. Send events as they happen in
your billing system.

## Authentication

Requests use the platform's standard server-to-server HMAC. Send three headers:

<ParamField path="X-API-Key" type="string" required>
  Your app's publishable key (`pk_live_...` or `pk_test_...`). Resolves which app the event
  belongs to.
</ParamField>

<ParamField path="X-Signature" type="string" required>
  HMAC-SHA256 over the **composite signing string** (below), keyed by your app's secret key
  (`sk_...`), hex-encoded.
</ParamField>

<ParamField path="X-Timestamp" type="string" required>
  Unix timestamp in **seconds**. Must be within **±300 seconds (5 minutes)** of Encore's
  server time or the request is rejected with `401`. Use the same value here that you sign.
</ParamField>

### Signing a request

The signed string is **not the body alone** — it is the composite:

```
<unix_timestamp>.POST./webhooks/encore.<raw_body>
```

* `<unix_timestamp>` is the exact value you send in `X-Timestamp`.
* The method is the literal `POST`.
* The path is the literal `/webhooks/encore` — **note this is the path the API serves
  internally, without the `/encore` prefix that appears in the public URL.** Sign
  `/webhooks/encore`, even though you POST to `…/encore/webhooks/encore`.
* `<raw_body>` is the exact bytes of the JSON body you transmit. Serialize once, then sign
  and send the identical bytes — do not re-serialize after signing (property reordering or
  whitespace changes will break the signature).

This `buildEncoreHeaders` helper is the one signing implementation referenced everywhere in
these docs:

```javascript theme={null}
import crypto from 'node:crypto';

// The path the API serves internally — the public `/encore` ingress prefix is
// stripped before the signature is checked, so it is NOT part of the signed path.
const SIGNING_PATH = '/webhooks/encore';

/**
 * Build the auth headers for a request to POST /webhooks/encore.
 * Pass the EXACT raw JSON body string you will transmit.
 */
function buildEncoreHeaders(rawBody, { publishableKey, secretKey }) {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signingString = `${timestamp}.POST.${SIGNING_PATH}.${rawBody}`;
  const signature = crypto
    .createHmac('sha256', secretKey)
    .update(signingString)
    .digest('hex');

  return {
    'X-API-Key': publishableKey,
    'X-Signature': signature,
    'X-Timestamp': timestamp,
    'Content-Type': 'application/json',
  };
}

// Serialize ONCE, then sign and send the exact same bytes.
const rawBody = JSON.stringify(event);
const headers = buildEncoreHeaders(rawBody, {
  publishableKey: 'pk_live_your_publishable_key',
  secretKey: process.env.ENCORE_SECRET_KEY,
});

await fetch('https://api.encorekit.com/encore/webhooks/encore', {
  method: 'POST',
  headers,
  body: rawBody,
});
```

## Payload

The request body is JSON. **`user.app_account_id`, `subscription.original_transaction_id`,
`event_id`, and `event_type` are required on every event.**

<ParamField path="event_id" type="string" required>
  Unique id for this event. Encore uses it as the **idempotency / dedup key**, so retrying
  with the same `event_id` collapses to a single event — safe to retry.
</ParamField>

<ParamField path="event_type" type="string" required>
  The Encore-standard verb describing what happened. One of:

  `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`.
</ParamField>

<ParamField path="user" type="object" required>
  The subscriber. **Required on every event.**

  <ParamField path="user.app_account_id" type="string" required>
    The Encore app account id for this user — the value you read on the client with
    [`getAppAccountId()`](#getappaccountid) and carry through your billing provider. **Required
    on every event** (`did_subscribe`, `did_renew`, `did_cancel`, … all of them). Omitting it
    fails validation and the event is dropped. See [How the join works](#how-the-join-works).
  </ParamField>

  <ParamField path="user.publisher_user_id" type="string">
    Optional. Your own user id, for cross-checking `identify()`.
  </ParamField>
</ParamField>

<ParamField path="subscription" type="object" required>
  The subscription this event belongs to. **Required on every event.**

  <ParamField path="subscription.original_transaction_id" type="string" required>
    The subscription chain anchor — your biller's subscription id (for example, a Stripe
    subscription id). **Required on every event.** Stays constant across the entire lifecycle of
    one subscription.
  </ParamField>

  <ParamField path="subscription.product_id" type="string">
    Your product identifier.
  </ParamField>

  <ParamField path="subscription.plan" type="string">
    The plan or pricing tier.
  </ParamField>

  <ParamField path="subscription.price_amount_micros" type="number">
    Price in micros (1,000,000 micros = 1 unit of currency). Supplying price directly enables
    revenue-lift measurement on day one.
  </ParamField>

  <ParamField path="subscription.price_currency" type="string">
    ISO 4217 currency code (for example, `USD`).
  </ParamField>

  <ParamField path="subscription.is_trial_start" type="boolean">
    `true` when this event starts a free trial.
  </ParamField>

  <ParamField path="subscription.store" type="string">
    The billing source (for example, `stripe`).
  </ParamField>

  <ParamField path="subscription.country_code" type="string">
    ISO 3166-1 alpha-2 country code.
  </ParamField>
</ParamField>

<ParamField path="occurred_at" type="string">
  RFC3339 timestamp of when the event occurred (for example, `2026-06-24T18:30:00Z`).
  Optional — defaults to receipt time if absent.
</ParamField>

<ParamField path="raw_payload" type="object">
  Optional. Your own wire payload, preserved verbatim. Encore stores it alongside the
  canonical event for your reference.
</ParamField>

### Example payload

The canonical example — both required identifiers (`user.app_account_id` and
`subscription.original_transaction_id`) are present, as they must be on **every** event:

```json theme={null}
{
  "event_id": "evt_01HZX...",
  "event_type": "did_subscribe",
  "occurred_at": "2026-06-24T18:30:00Z",
  "user": {
    "app_account_id": "user-123"
  },
  "subscription": {
    "original_transaction_id": "sub_1P9aBcD2eF",
    "product_id": "pro_monthly",
    "plan": "monthly",
    "price_amount_micros": 9990000,
    "price_currency": "USD",
    "is_trial_start": false,
    "store": "stripe",
    "country_code": "US"
  }
}
```

A later event (for example `did_renew`) has the same shape and **still carries
`user.app_account_id`** — only the lifecycle-specific fields differ.

## How the join works

Encore links `subscription.original_transaction_id` (the chain anchor) to
`user.app_account_id` (the persistent person id) on **every** event, and that link is what
resolves a subscription back to the web exposure Encore recorded for the same person.

Because the link is (re)written on every event, **`user.app_account_id` must be present on
every event** — not just the first. An event missing it fails validation and is dropped, so
that subscription cannot be attributed. Persist the app account id when you first see a
subscription and attach it to every event you forward. The
[Subscription Webhook guide](/publishers/web/guides/subscription-webhook) shows this with
Stripe.

## Responses

<ResponseField name="200 OK — accepted" type="object">
  ```json theme={null}
  { "success": true, "message": "Received evt_01HZX..." }
  ```

  The event was authenticated, validated, and forwarded for processing.
</ResponseField>

<ResponseField name="200 OK — accepted, processing deferred" type="object">
  ```json theme={null}
  { "success": true, "message": "Received (processing deferred)" }
  ```

  The request authenticated, but an **internal error past auth** occurred (for example a
  malformed JSON body or a payload that fails validation — such as a missing
  `user.app_account_id`). Encore returns `200` here deliberately, to prevent retry storms.

  **Important:** a `200` is *not* proof of durable storage. The `"processing deferred"` message
  specifically means the event was acknowledged but may have been **dropped**. Treat only the
  `"Received <event_id>"` message as a successful ingest; if you see `"processing deferred"`,
  inspect your payload (most often a validation failure).
</ResponseField>

<ResponseField name="401 Unauthorized" type="object">
  ```json theme={null}
  { "success": false, "error": "..." }
  ```

  Authentication failed: missing HMAC headers, a signature mismatch, an unknown key, or an
  `X-Timestamp` outside the ±300s window. Auth failures fail loud (unlike post-auth errors).
</ResponseField>

## Idempotency

`event_id` is the dedup key end to end. Re-POSTing an event with the same `event_id`
collapses to a single stored event, so retries are safe.

## getAppAccountId()

Returns the persistent Encore app account id for the current user — the value to carry
through your billing provider and send as `user.app_account_id`.

### Signature

```typescript theme={null}
function getAppAccountId(): string | null
```

### Return Value

**Type:** `string | null`

* Your own user id if [`identify()`](/publishers/web/sdk-reference/identify) was called.
* An auto-generated anonymous id otherwise.
* `null` if the SDK has not initialized yet.

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

const appAccountId = Encore.getAppAccountId();
```

<Tip>
  Call [`Encore.identify(yourStableUserId)`](/publishers/web/sdk-reference/identify) so the app
  account id is your own stable user id rather than an anonymous, per-device id. The same user
  then resolves to the same `app_account_id` everywhere, making the join robust across devices
  and sessions.
</Tip>

<Note>
  `getAppAccountId()` is the canonical accessor for this use case.
  [`getCurrentUserId()`](/publishers/web/sdk-reference/identify#getcurrentuserid) returns the
  same underlying id.
</Note>

## Next steps

* **[Subscription Webhook guide](/publishers/web/guides/subscription-webhook)** — the
  end-to-end Stripe walkthrough that uses this contract.
* **[identify()](/publishers/web/sdk-reference/identify)** — set a stable user id so the app
  account id is consistent across devices.
