Webhooks

EntitleHub POSTs a signed event to your backend whenever a subscriber's state changes, so you can update your own database, kick off emails, or sync analytics without polling.

Register an endpoint

In Dashboard → Webhooks, add your HTTPS endpoint URL. EntitleHub generates a signing secret (whsec_…): store it in your backend env. The dashboard shows a delivery log with each attempt and response.

Event payload

json
{
  "id": "evt_…",                       // unique, use it for idempotency
  "type": "initial_purchase",           // see event types below
  "app_id": "proj_…",
  "app_user_id": "user_123",
  "store": "play",
  "product": "app_pro_monthly",
  "entitlement": "pro",
  "active_entitlements": ["pro"],       // the subscriber's full active set after this event
  "environment": "production",           // or "sandbox"
  "revenue_micros": 9990000,             // money booked, negative on a refund, absent if none
  "currency": "USD",
  "created_at": "2026-07-18T00:00:00Z"
}

revenue_micros is what EntitleHub actually recorded for the event, not the catalog price: it is absent or zero for a sandbox purchase, a Play promo/rewarded purchase, a manual grant, a trial starting, or a duplicate store notification for a renewal cycle already counted. Want these events as an email or phone push instead of an HTTP call? See Alerts & digests.

Sandbox purchases & restores

TestFlight / sandbox purchases fire webhooks just like production ones, with the full entitlement data and "environment": "sandbox". The subscriber's active_entitlements (and is_sandbox on entitlements in API responses) include sandbox-backed access so your app can unlock during testing, a production purchase always outranks a sandbox one for the same entitlement. Filter on environment before counting revenue or triggering anything money-related.

Restore purchases and app relaunches re-post the same store transaction; EntitleHub treats those as idempotent: the entitlement is refreshed but no new initial_purchase event fires. One store transaction, one sale.

Event types

  • initial_purchase: first purchase of a product.
  • renewal: a subscription renewed.
  • cancellation: auto-renew turned off (still active until expiry).
  • expiration: access ended.
  • billing_issue: payment failed / entered grace.
  • refund: a purchase was refunded / revoked.
  • trial_started, trial_converted: trial lifecycle.
  • override: a direct grant (no store purchase), e.g. a promo or code.
Renewals & lifecycle

initial_purchase and override fire on purchase / grant. renewal, cancellation, expiration, billing_issue, and refund fire from Apple / Google server-to-server notifications once you wire those up: see Store credentials.

Verify the signature

Each request includes an EntitleHub-Signature header. Recompute the HMAC over {timestamp}.{raw body} with your signing secret and compare, Stripe-style.

typescript
import crypto from "crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  // header looks like:  t=1737158400,v1=9f86d081...
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const expected = crypto.createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  // constant-time compare
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ""));
}

// In your handler: read the RAW body (not parsed) before verifying.

Delivery & retries

  • Respond 2xx quickly (within a few seconds). Do slow work asynchronously.
  • Failed deliveries are retried with backoff; every attempt is logged in the dashboard.
  • Deliveries can duplicate: dedupe on the event id.
  • After 10 consecutive failed deliveries an endpoint is paused automatically and we email you. Fix it, then re-enable it in Dashboard → Webhooks. Events that occurred while it was paused are not replayed.
Return 2xx for events you don't care about

A non-2xx is a request to retry. If an event carries an app_user_id you cannot resolve, or an entitlement you do not map, acknowledge it anyway. Failing on input you were never going to act on burns three attempts per event and counts toward the pause threshold above. Reserve non-2xx for genuinely transient problems, like your database being briefly unreachable.