BACKEND / WEBHOOKS

Webhooks.

Every purchase, renewal, cancellation and refund, sent to your backend as it happens. The format is RevenueCat's, so a backend written for RevenueCat webhooks works unchanged.

01

Set up an endpoint

  1. In the panel, open Integrations → Webhooks and add your URL.
  2. Choose the event types it receives.
  3. Copy the signing secret and verify every delivery with it.
  4. Click Send test event to send a TEST event, whatever types the endpoint listens for.

An endpoint can also send a fixed Authorization header with every delivery, for backends that already check one.

Endpoints receive only events recorded while they are active. Turning one on later does not replay earlier events.

02

Payload

Each delivery is a POST with a JSON body:

{
  "api_version": "1.0",
  "event": {
    "id": "6f1d1c1e-7a1b-4f0e-9a52-0b1f4d3e2c11",
    "type": "RENEWAL",
    "event_timestamp_ms": 1769904000000,
    "app_id": "app_8c1f",
    "app_user_id": "user_123",
    "original_app_user_id": "user_123",
    "aliases": ["user_123", "$anonymous_9f2c"],
    "store": "PLAY_STORE",
    "environment": "PRODUCTION",
    "product_id": "premium:monthly",
    "benefit_ids": ["premium"],
    "presented_package_id": "default",
    "period_type": "NORMAL",
    "purchased_at_ms": 1769904000000,
    "expiration_at_ms": 1772582400000,
    "transaction_id": "GPA.3345-1234-5678-90123..1",
    "original_transaction_id": "GPA.3345-1234-5678-90123",
    "renewal_number": 2,
    "is_trial_conversion": false,
    "is_family_share": false,
    "country_code": "TR",
    "currency": "TRY",
    "price": 5.12,
    "price_in_purchased_currency": 149.99,
    "tax_percentage": 0.1667,
    "commission_percentage": 0.125,
    "takehome_percentage": 0.85,
    "subscriber_attributes": {
      "$email": { "value": "[email protected]", "updated_at_ms": 1767225600000 }
    }
  }
}
FieldDescription
idThe event's ID. Use it to ignore a repeat delivery.
app_user_id, original_app_user_id, aliasesThe customer the event is for, their original ID, and every ID they are known by.
benefit_idsThe benefits the product grants, in place of RevenueCat's entitlement_ids.
presented_package_idThe package the app showed when the customer first bought the subscription. On every event of the subscription, or null.
period_typeTRIAL, INTRO, PROMOTIONAL or NORMAL.
price, price_in_purchased_currency, currencyThe price including tax, in USD and in the customer's currency. Negative for refunds.
tax_percentage, commission_percentageShares of price: price × (1 − commission − tax) is your proceeds. Rounded to four decimals.
takehome_percentageThe share of the price before tax left after the store's fee.
renewal_numberThe period a payment is for: the first purchase is 1, its first renewal 2.
cancel_reason, expiration_reasonOn cancellations and expirations. See below.
grace_period_expiration_at_msOn billing issues, when the grace period ends, or null.
new_product_idOn product changes, the product switched to.
transferred_from, transferred_toOn transfers, the customer IDs.
subscriber_attributesThe customer's attributes, each with value and updated_at_ms.
03

Event types

What happenedWebhook typeNotes
New paid subscriptionINITIAL_PURCHASEperiod_type NORMAL, or INTRO / PROMOTIONAL for offers.
New subscription with a free trialINITIAL_PURCHASEperiod_type: "TRIAL".
Renewal, or Play recovery from account holdRENEWALis_trial_conversion is true for the first payment after a trial.
Upgrade or downgradePRODUCT_CHANGE
One-time purchaseNON_RENEWING_PURCHASE
Auto-renew turned offCANCELLATIONcancel_reason UNSUBSCRIBE, or Play's BILLING_ERROR / DEVELOPER_INITIATED.
RefundCANCELLATIONcancel_reason: "CUSTOMER_SUPPORT", negative price.
Auto-renew turned back onUNCANCELLATION
Failed renewal, grace period, Play account holdBILLING_ISSUEFollowed by a CANCELLATION with cancel_reason: "BILLING_ERROR", once per failed period.
PausedSUBSCRIPTION_PAUSED
Renewal date extended, Play deferralSUBSCRIPTION_EXTENDED
ExpiredEXPIRATIONexpiration_reason UNSUBSCRIBE, BILLING_ERROR, PRICE_INCREASE, DEVELOPER_INITIATED or UNKNOWN.
Revoked, including Family Sharing revocationEXPIRATIONexpiration_reason: "CUSTOMER_SUPPORT".
Refund reversedREFUND_REVERSED
Promotional grantNON_RENEWING_PURCHASEstore: "PROMOTIONAL", price 0, expiration_at_ms null for lifetime, plus grant_id and grant_source.
Promotional grant revokedEXPIRATIONstore: "PROMOTIONAL", expiration_reason: "DEVELOPER_INITIATED".
Purchases moved to another customerTRANSFERSent for the recipient only.
Apple renewal extensionTEMPORARY_ENTITLEMENT_GRANT

Trial starts, refunds and promotional grants can be chosen on their own when you set up an endpoint (TRIAL, REFUND, PROMOTIONAL_GRANT, PROMOTIONAL_REVOKE). They are delivered as the types above, so an endpoint listening for INITIAL_PURCHASE, CANCELLATION, NON_RENEWING_PURCHASE or EXPIRATION gets them too.

Each store payment produces one event, whichever source reports it first: the SDK, a store notification, the subscription refresher or an import.

04

Verify deliveries

Every delivery carries three headers:

HeaderValue
X-Revfox-Signaturesha256= and the hex HMAC-SHA256 of the raw body, keyed with the endpoint's secret.
X-Revfox-EventThe event type.
X-Revfox-DeliveryThe delivery's ID. A resend gets a new one; the event id stays the same.

Compute the HMAC over the body exactly as received, before parsing it, and compare in constant time.

// Node.js (Express)
import crypto from "node:crypto";

app.post("/revfox", express.raw({ type: "application/json" }), (req, res) => {
  const expected = "sha256=" + crypto.createHmac("sha256", process.env.REVFOX_WEBHOOK_SECRET).update(req.body).digest("hex");
  const given = req.get("X-Revfox-Signature") ?? "";
  if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  const { event } = JSON.parse(req.body);
  // handle event.type, ignoring event.id values you have already processed
  res.sendStatus(200);
});
// Go
func verify(body []byte, header, secret string) bool {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(body)
	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(header), []byte(expected))
}
05

Delivery and retries

  • Answer with any 2xx within 15 seconds. Anything else, or no answer, is a failure.
  • A failed delivery is retried 5 times, after 5, 10, 20, 40 and 80 minutes.
  • Every attempt is listed on the webhook's page and on the customer's event page, with the response. Resend there starts a new round of attempts.
  • Deliveries can arrive more than once and out of order. Use the event id to skip repeats, and the timestamps rather than arrival order to decide the latest state. When in doubt, read the customer from the server API.
06

Amplitude and Mixpanel

Integrations → Analytics sends the same events to Amplitude or Mixpanel, through the same delivery, retry and log pipeline. Events are named revfox_<type>, with revfox_trial_started and revfox_refund told apart from other purchases and cancellations. They carry the event's fields as properties and are sent for the customer's ID. Payments and refunds carry revenue, and the event ID is the deduplication ID.

Coming from another platform?

Import your customers and purchases.

Migration