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

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:
X-API-Key
string
required
Your app’s publishable key (pk_live_... or pk_test_...). Resolves which app the event belongs to.
X-Signature
string
required
HMAC-SHA256 over the composite signing string (below), keyed by your app’s secret key (sk_...), hex-encoded.
X-Timestamp
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.

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/encorenote 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:
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.
event_id
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.
event_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.
user
object
required
The subscriber. Required on every event.
user.app_account_id
string
required
The Encore app account id for this user — the value you read on the client with 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.
user.publisher_user_id
string
Optional. Your own user id, for cross-checking identify().
subscription
object
required
The subscription this event belongs to. Required on every event.
subscription.original_transaction_id
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.
subscription.product_id
string
Your product identifier.
subscription.plan
string
The plan or pricing tier.
subscription.price_amount_micros
number
Price in micros (1,000,000 micros = 1 unit of currency). Supplying price directly enables revenue-lift measurement on day one.
subscription.price_currency
string
ISO 4217 currency code (for example, USD).
subscription.is_trial_start
boolean
true when this event starts a free trial.
subscription.store
string
The billing source (for example, stripe).
subscription.country_code
string
ISO 3166-1 alpha-2 country code.
occurred_at
string
RFC3339 timestamp of when the event occurred (for example, 2026-06-24T18:30:00Z). Optional — defaults to receipt time if absent.
raw_payload
object
Optional. Your own wire payload, preserved verbatim. Encore stores it alongside the canonical event for your reference.

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:
{
  "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 shows this with Stripe.

Responses

200 OK — accepted
object
{ "success": true, "message": "Received evt_01HZX..." }
The event was authenticated, validated, and forwarded for processing.
200 OK — accepted, processing deferred
object
{ "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).
401 Unauthorized
object
{ "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).

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

function getAppAccountId(): string | null

Return Value

Type: string | null
  • Your own user id if identify() was called.
  • An auto-generated anonymous id otherwise.
  • null if the SDK has not initialized yet.
import Encore from '@encorekit/web-sdk';

const appAccountId = Encore.getAppAccountId();
Call Encore.identify(yourStableUserId) 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.
getAppAccountId() is the canonical accessor for this use case. getCurrentUserId() returns the same underlying id.

Next steps