Set up an endpoint
- In the panel, open Integrations → Webhooks and add your URL.
- Choose the event types it receives.
- Copy the signing secret and verify every delivery with it.
- Click Send test event to send a
TESTevent, 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.
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 }
}
}
}
| Field | Description |
|---|---|
id | The event's ID. Use it to ignore a repeat delivery. |
app_user_id, original_app_user_id, aliases | The customer the event is for, their original ID, and every ID they are known by. |
benefit_ids | The benefits the product grants, in place of RevenueCat's entitlement_ids. |
presented_package_id | The package the app showed when the customer first bought the subscription. On every event of the subscription, or null. |
period_type | TRIAL, INTRO, PROMOTIONAL or NORMAL. |
price, price_in_purchased_currency, currency | The price including tax, in USD and in the customer's currency. Negative for refunds. |
tax_percentage, commission_percentage | Shares of price: price × (1 − commission − tax) is your proceeds. Rounded to four decimals. |
takehome_percentage | The share of the price before tax left after the store's fee. |
renewal_number | The period a payment is for: the first purchase is 1, its first renewal 2. |
cancel_reason, expiration_reason | On cancellations and expirations. See below. |
grace_period_expiration_at_ms | On billing issues, when the grace period ends, or null. |
new_product_id | On product changes, the product switched to. |
transferred_from, transferred_to | On transfers, the customer IDs. |
subscriber_attributes | The customer's attributes, each with value and updated_at_ms. |
Event types
| What happened | Webhook type | Notes |
|---|---|---|
| New paid subscription | INITIAL_PURCHASE | period_type NORMAL, or INTRO / PROMOTIONAL for offers. |
| New subscription with a free trial | INITIAL_PURCHASE | period_type: "TRIAL". |
| Renewal, or Play recovery from account hold | RENEWAL | is_trial_conversion is true for the first payment after a trial. |
| Upgrade or downgrade | PRODUCT_CHANGE | |
| One-time purchase | NON_RENEWING_PURCHASE | |
| Auto-renew turned off | CANCELLATION | cancel_reason UNSUBSCRIBE, or Play's BILLING_ERROR / DEVELOPER_INITIATED. |
| Refund | CANCELLATION | cancel_reason: "CUSTOMER_SUPPORT", negative price. |
| Auto-renew turned back on | UNCANCELLATION | |
| Failed renewal, grace period, Play account hold | BILLING_ISSUE | Followed by a CANCELLATION with cancel_reason: "BILLING_ERROR", once per failed period. |
| Paused | SUBSCRIPTION_PAUSED | |
| Renewal date extended, Play deferral | SUBSCRIPTION_EXTENDED | |
| Expired | EXPIRATION | expiration_reason UNSUBSCRIBE, BILLING_ERROR, PRICE_INCREASE, DEVELOPER_INITIATED or UNKNOWN. |
| Revoked, including Family Sharing revocation | EXPIRATION | expiration_reason: "CUSTOMER_SUPPORT". |
| Refund reversed | REFUND_REVERSED | |
| Promotional grant | NON_RENEWING_PURCHASE | store: "PROMOTIONAL", price 0, expiration_at_ms null for lifetime, plus grant_id and grant_source. |
| Promotional grant revoked | EXPIRATION | store: "PROMOTIONAL", expiration_reason: "DEVELOPER_INITIATED". |
| Purchases moved to another customer | TRANSFER | Sent for the recipient only. |
| Apple renewal extension | TEMPORARY_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.
Verify deliveries
Every delivery carries three headers:
| Header | Value |
|---|---|
X-Revfox-Signature | sha256= and the hex HMAC-SHA256 of the raw body, keyed with the endpoint's secret. |
X-Revfox-Event | The event type. |
X-Revfox-Delivery | The 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))
}
Delivery and retries
- Answer with any
2xxwithin 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
idto skip repeats, and the timestamps rather than arrival order to decide the latest state. When in doubt, read the customer from the server API.
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.
