RevenueCat
Bring subscription events into Pushlane.
#How it works
Pushlane receives RevenueCat subscription events through a server-to-server webhook. Each incoming event is authenticated, deduplicated, and written to your revenue ledger. For most event types Pushlane also emits a synthetic event into your flow engine — so a flow can trigger on revenuecat.trial_started and send a push the moment a trial begins, with the expiration timestamp available as a branch condition.
Identity linking is the critical piece: Pushlane attributes a purchase to a user by matching RevenueCat's ids against the ids it already knows. It checks app_user_id (the current id — what Purchases.appUserID returns), then original_app_user_id, then every entry in aliases, and the first one it already knows wins. The rest are recorded as aliases, so a later Purchases.logIn() still lands on the same user. The convenience method Pushlane.identifyForRevenueCat keeps both SDKs in sync at one call site.
$RCAnonymousID:… values get no special treatment — they are ordinary external ids. If your app has no sign-up, pass Purchases.appUserID to Pushlane.identify() and the purchase resolves to the same Pushlane user your SDK events already created, in both directions.#Step 1 — share the same user id
In your iOS app, replace separate Pushlane.identify and Purchases.configure calls with the single convenience method. It calls Pushlane.identify internally and returns the id so you can chain it directly.
import RevenueCat
import PushlaneCore
// Use ONE id for both SDKs — this is what links a purchase to a Pushlane user.
// Pushlane.identifyForRevenueCat calls Pushlane.identify and returns the id so you
// can chain it directly into Purchases.configure.
let uid = Pushlane.identifyForRevenueCat(currentUser.id)
Purchases.configure(withAPIKey: "appl_…", appUserID: uid)
// On login later, keep both SDKs in lockstep at one call site:
Purchases.shared.logIn(uid) { _, _, _ in }
Pushlane.identifyForRevenueCat(uid)original_app_user_id or an alias if the current id drifts, but that only works once RevenueCat has actually told us about the link — the reliable path is one id, set at one call site.No accounts in your app? Then there is no id to share — use RevenueCat's own. Purchases.appUserID always returns something (an $RCAnonymousID:… value until you call logIn), and it is exactly what the webhooks carry:
import RevenueCat
import PushlaneCore
Purchases.configure(withAPIKey: "appl_…")
// No sign-up in this app? RevenueCat's id IS the shared id.
// Anonymous ($RCAnonymousID:…) is fine — it is a normal external id to Pushlane.
Pushlane.identify(Purchases.shared.appUserID)
// If accounts arrive later, logIn both SDKs together and RevenueCat aliases
// the anonymous id to the real one — Pushlane follows that alias.#Step 2 — generate your webhook secret
In Pushlane → Settings → Integrations → RevenueCat, click Generate webhook secret. Pushlane creates a dedicated high-entropy secret (rcw_…) for your workspace, stores it encrypted, and shows it exactly once — copy it immediately. It can never be re-displayed; if you lose it, rotate it (the old value stops working the moment you do).
lpk_live_… key ships inside your app binary and is publishable by design — anyone can extract it, so it must never authenticate revenue or entitlement webhooks. Only the server-side rcw_… secret does.#Step 3 — point the webhook at Pushlane
In RevenueCat → Project → Integrations → Webhooks, add a new webhook with the URL below and set the custom Authorization header value to the rcw_… webhook secret you generated in step 2.
POST https://<your-ingest-url>/v1/webhooks/revenuecat
Authorization: rcw_… ← your RevenueCat webhook secret (NOT the SDK write-key)tenantId query parameter is needed. A missing or unknown secret is rejected with 401, and a secret can only ever reach its own tenant.Enable at minimum INITIAL_PURCHASE, RENEWAL, CANCELLATION and EXPIRATION. Enabling all events is fine and recommended: a type Pushlane does not model is acknowledged with 200 {"ok":true,"ignored":true} and nothing is written — it is never counted as an ingested event, and it never turns into a retry loop.
TRIAL_STARTED checkbox to look for — RevenueCat has no such webhook type. A free trial arrives as INITIAL_PURCHASE. See the next section.#Events Pushlane handles
Pushlane models the twelve RevenueCat webhook types below. Ten of them are bridged into the flow engine so you can trigger a push from them. Two — TRANSFER and TEMPORARY_ENTITLEMENT_GRANT — are still persisted to Pushlane's revenue tables but never enter flows (they are internal RevenueCat bookkeeping, not user-driven signals).
| RevenueCat type | Flow event name | Bridged to flows |
|---|---|---|
| INITIAL_PURCHASE | revenuecat.initial_purchase | Yes |
| RENEWAL | revenuecat.renewal | Yes |
| CANCELLATION | revenuecat.cancellation | Yes |
| EXPIRATION | revenuecat.expiration | Yes |
| BILLING_ISSUE | revenuecat.billing_issue | Yes |
| PRODUCT_CHANGE | revenuecat.product_change | Yes |
| NON_RENEWING_PURCHASE | revenuecat.non_renewing_purchase | Yes |
| UNCANCELLATION | revenuecat.uncancellation | Yes |
| SUBSCRIPTION_PAUSED | revenuecat.subscription_paused | Yes |
| SUBSCRIPTION_EXTENDED | revenuecat.subscription_extended | Yes |
| TRANSFER | — | No (identity bookkeeping) |
| TEMPORARY_ENTITLEMENT_GRANT | — | No (internal RC grant) |
Every other type RevenueCat sends — SUBSCRIBER_ALIAS, INVOICE_ISSUANCE, REFUND_REVERSED, the PAYWALL_* family, EXPERIMENT_ENROLLMENT, PRICE_INCREASE_CONSENT_* and anything added in the future — is acknowledged with 200 and not persisted. Turning them on in RevenueCat is harmless.
#Trial events are derived, not received
RevenueCat has no TRIAL_STARTED or TRIAL_CONVERTED webhook type. A free trial arrives as an INITIAL_PURCHASE carrying period_type: "TRIAL", and its conversion arrives as a RENEWAL carrying is_trial_conversion: true. Pushlane reads those fields and emits the trial events for you:
| What happened | RevenueCat sends | Pushlane emits |
|---|---|---|
| Free trial started | INITIAL_PURCHASE + period_type: TRIAL | revenuecat.initial_purchase and revenuecat.trial_started |
| Trial converted to paid | RENEWAL + is_trial_conversion: true | revenuecat.renewal and revenuecat.trial_converted |
revenuecat.initial_purchase keeps behaving exactly as before. Both events carry the same properties, so revenuecat_type on a revenuecat.trial_started row reads INITIAL_PURCHASE — the raw type, always.RENEWAL arrives without the is_trial_conversion field, Pushlane derives nothing. A second renewal is otherwise indistinguishable from a conversion, and guessing would fire "welcome to paid" pushes at long-standing subscribers.#Build an end-of-trial flow
This is the main reason the trial events exist. Trigger on revenuecat.trial_started and you get one entry per trial, at the moment it starts, with the trial end already on the event.
Trigger: revenuecat.trial_started
├─ Delay 2 days ← 3-day trial: land a day before it ends
├─ Branch: is_production == true ← optional, skips sandbox purchases
└─ Push "Your trial ends tomorrow"
// Want to be exact instead of approximate? expiration_at_ms is the real trial
// end (epoch ms), so a branch can gate on how much time is actually left.
// Cancels are not silent either: revenuecat.cancellation fires the moment a
// user turns auto-renew off — usually LONG before expiration_at_ms.#Event properties in flows
When a RevenueCat event enters a flow, these properties are available on the event node for branch conditions and notification variables. They are the same across all bridged types — only their values differ.
// A flow triggered on revenuecat.trial_started has these properties
// available on the event (usable in branch conditions):
{
"product_id": "com.myapp.premium_monthly",
"store": "APP_STORE", // APP_STORE | PLAY_STORE | …
"environment": "PRODUCTION", // PRODUCTION | SANDBOX
"period_type": "TRIAL", // TRIAL | INTRO | NORMAL
"currency": "USD",
"price": 0.0, // gross, original currency
"net_revenue": 0.0, // price × (1 − commission) × (1 − tax)
"is_trial": true,
"is_trial_conversion": false,
"is_production": true,
"reward_eligible": true,
"purchased_at_ms": 1719878400000,
"expiration_at_ms": 1722470400000, // trial end — use in delay nodes
"revenuecat_event_id": "abc123…",
"revenuecat_type": "INITIAL_PURCHASE", // the RAW RevenueCat type, always
"entitlement_ids": ["premium"] // present when RC sends them
}expiration_at_ms is particularly useful: it tells you exactly when the trial or subscription period ends. You can use it in a branch to gate a "trial expiring soon" push on how much time is left.#Sandbox and test events
Send test event. The RevenueCat dashboard provides a Send test event button (in the webhook configuration view) that emits an event of type TEST. Pushlane acknowledges it with 200 {"ok":true,"test":true} without persisting anything to the revenue tables or entering any flow. This happens after authentication — a test event with a missing or invalid webhook secret still gets 401 — so a green result in the RevenueCat dashboard confirms that the Authorization header is correctly configured for your workspace.
Sandbox subscriptions. Pushlane records all non-test events regardless of environment, but sandbox events are never credited to the reward ledger. An event is reward-eligible only when environment === "PRODUCTION" and store is not PROMOTIONAL or TEST_STORE.
Flow execution is not gated on environment — a sandbox revenuecat.trial_started will enter any matching flow and can send a push. Use a branch condition on is_production if you want to skip sandbox in production flows.
#Deduplication
Pushlane deduplicates on RevenueCat's event.id. A replayed webhook for the same event id returns 200 {"ok":true,"deduped":true} without re-inserting any row or re-entering any flow. It is safe for RevenueCat to retry.
Next: Building flows — trigger a flow on revenuecat.trial_started and send a push when a trial begins.