Outbound Webhooks
Receive Rovenue subscription, revenue, funnel, and credit-ledger events on your own HTTPS endpoint — payload shape, signature verification, retries, and idempotency.
Outbound Webhooks
The Custom Webhook integration POSTs a JSON event to an HTTPS endpoint you control whenever a subscription, revenue, funnel-session, or credit-ledger event happens in your project. It is signed with the same scheme Svix uses, so most existing Svix-compatible webhook libraries verify it without modification.
1. Add a webhook endpoint
- In the Rovenue dashboard, navigate to Apps (left sidebar).
- Find Custom Webhook in the integrations grid and click it.
- Enter your endpoint URL (must be
https://in production) and choose which events to receive. - Click Save. The response includes a signing secret in the form
whsec_...— copy it now. It is shown once, at creation time; Rovenue only stores it encrypted and later shows a truncated hint (e.g.example.com · …ab12). - Use the Delivery Log on the connection to inspect past attempts, HTTP status codes, and response bodies, and to manually redeliver a specific past event.
2. Payload envelope
Every delivery body is JSON with this shape:
{
"id": "cksubEvt1a2b3c4d5e6f7g8h9",
"type": "revenue.RENEWAL",
"created": "2026-08-24T12:00:00.000Z",
"apiVersion": "2026-08-24",
"projectId": "ckprojectAbCdEfGhIjKlMn",
"data": { }
}| Field | Description |
|---|---|
id | The originating event's stable identifier. Use this for idempotency — see below for how it behaves on retries and manual redeliver. |
type | One of the event keys below. |
created | ISO-8601 timestamp of when the underlying event occurred (not when it was delivered). |
apiVersion | A date-versioned string identifying the payload schema, currently 2026-08-24. Bump only accompanies a documented breaking change to the envelope or data shape. |
projectId | The Rovenue project the event belongs to. |
data | Event-specific payload — see below. |
data shape
For revenue events (revenue.*), data is a fixed shape:
{
"kind": "RENEWAL",
"amount": "9.99",
"currency": "USD",
"subscriberId": "cksubscriber1a2b3c4d5e6f7g",
"productId": "com.example.app.monthly",
"externalId": "your-app-user-id"
}amountis a decimal string in the major currency unit (e.g."9.99", not cents).currencyis the ISO 4217 code.externalIdis theexternalIdyou passed to the SDK's identify call, if any — no other PII (email, phone, IP, etc.) is ever included in a webhook payload.reasonis present only on somerevenue.REACTIVATIONevents — see one-time purchase revenue keys below. Every other revenue event omits the key entirely rather than sending it asnull.
For every other event type (subscription.*, subscriber.identified, credit.ledger.appended), data is that event's domain payload passed through as-is.
For funnel events (funnel.session.*), data is a curated, camelCase object — not a passthrough of the internal payload. A field that has no value for that event (an empty native sessionId, a web event's missing placementId) is omitted entirely, never sent as null or "":
{
"sessionId": "cksession1a2b3c4d5e6f7g8h",
"funnelId": "ckfunnel1a2b3c4d5e6f7g8h9i",
"versionId": "ckversion1a2b3c4d5e6f7g8h",
"target": "web",
"pageId": "welcome"
}{
"funnelId": "ckfunnel1a2b3c4d5e6f7g8h9i",
"revision": 2,
"target": "native",
"pageId": "onboarding-2",
"placementId": "ckplacement1a2b3c4d5e6f7g",
"variantId": "ckvariant1a2b3c4d5e6f7g8h",
"experimentKey": "onboarding-copy-test"
}{
"sessionId": "cksession1a2b3c4d5e6f7g8h",
"funnelId": "ckfunnel1a2b3c4d5e6f7g8h9i",
"versionId": "ckversion1a2b3c4d5e6f7g8h",
"target": "web",
"pageId": "pricing",
"actionId": "open_faq",
"params": {
"faqId": "refunds",
"source": "sticky-footer"
}
}A web event never carries revision (only native events do), and carries subscriberId on funnel.session.completed only: that is the event fired when a paid visitor claims the purchase in the app, so it is the first point where a web session has a subscriber. started, advanced, paid, abandoned and custom_action never have one.
A native event has no sessionId, because the device has no presentation id (see the note under funnel.session.dismissed below). It carries subscriberId whenever the device sent its Rovenue id with the event. Identifying is not required: every install has a Rovenue id, anonymous users included. The id on the webhook is the Rovenue subscriber id that the device's id resolved to. A GDPR-erased subject's events carry no subscriberId: they still arrive, but unattributed. pageId is the page the event is about: the landing page of an arrival, the opening page of a started, or the page a custom action fired on; fromPageId (web-only, forward moves) names the page it moved from. funnel.session.paid carries no amount — money is always reported separately on the revenue.* keys above.
params (only present on funnel.session.custom_action) is the author-defined key/value map attached to the action in the funnel document, and it is bounded before it reaches the wire — not a raw passthrough. In order: only string values survive (anything else is dropped); a key longer than 64 characters is dropped; the surviving keys are sorted and only the first 32 are kept (a deterministic cut, not "whichever happen to survive"); and each kept value is truncated to 512 characters. These bounds only make the body size predictable — a real action's params map is far smaller — but a receiver that expects every key it configured in the funnel to always be present should know keys can be silently dropped, and values silently truncated, under this rule.
Security note — native funnel events are client-asserted. A device funnel event (started, advanced, completed, dismissed) is submitted with the project's public SDK key, the same key that ships inside the app binary. Anyone holding that key — which is by design not a secret — can submit a native funnel event that never actually happened on a real device. Web funnel events (and every revenue.* event, including funnel.session.paid) are produced server-side and carry no such exposure. Do not treat a native funnel webhook as proof of anything a client could fake; if a receiver needs a trustworthy signal, use revenue.* or a web funnel event instead.
3. Event catalog
Kept in sync with
ROVENUE_EVENT_KEYSinpackages/shared/src/integrations.ts— that file is the source of truth if this list and the code ever drift.
| Event key | Fires when |
|---|---|
revenue.INITIAL | The first paid transaction for a subscriber (new subscription). Also the type recorded for any one-time purchase made before 2026-09-04 — see one-time purchase revenue keys below. |
revenue.TRIAL_CONVERSION | A trial converts to a paid subscription. |
revenue.RENEWAL | A recurring subscription renews for another billing period. |
revenue.CREDIT_PURCHASE | A consumable in-app product is purchased — Apple, Google, or a one-time Stripe funnel purchase of a consumable product. See one-time purchase revenue keys below. |
revenue.NON_RENEWING_PURCHASE | A non-consumable one-time purchase — Apple, Google, or a one-time Stripe funnel purchase of a non-consumable product. See one-time purchase revenue keys below. |
revenue.REACTIVATION | A lapsed subscriber returns — a win-back offer redemption or a plain resubscribe on any store — or an accounting reversal of a previously-applied refund (Apple only). All three stores — see one-time purchase revenue keys below for how the two cases are told apart internally. |
revenue.REFUND | A refund is recorded (data.amount is always a positive value). |
revenue.CANCELLATION | The subscription ended — the $0 marker written when a subscription reaches its end of term. It books no money and grants no credits. |
subscription.trial.started | A new trial period begins. |
subscriber.identified 1 | An SDK identify() call binds a Rovenue subscriber to your app-user id. |
subscription.cancel_requested | A subscriber requests cancellation (the subscription may still be active until period end). |
subscription.expired | A subscription reaches the EXPIRED state. |
subscription.billing_issue | A renewal payment fails and the subscriber does not keep access while the store retries (Apple DID_FAIL_TO_RENEW without the GRACE_PERIOD subtype, Apple GRACE_PERIOD_EXPIRED, Google SUBSCRIPTION_ON_HOLD, Stripe invoice.payment_failed). The grace-period half of Apple's DID_FAIL_TO_RENEW announces subscription.grace_period instead — see the row below. |
subscription.grace_period | A renewal payment fails but the subscriber keeps access while the store retries (Google SUBSCRIPTION_IN_GRACE_PERIOD, Apple DID_FAIL_TO_RENEW with subtype GRACE_PERIOD — Apple sends that subtype only when the app has a billing grace period configured — and Stripe past_due while smart retries run). |
subscription.uncancelled | A subscriber re-enables auto-renew after previously cancelling (Google SUBSCRIPTION_RESTARTED, Apple DID_CHANGE_RENEWAL_STATUS in the "switched back on" direction, Stripe cancel-at-period-end undo). |
subscription.product_changed | A subscription's product or price changes outside a cancellation. Delivered in two phases: an announcement that a change is coming (Apple DID_CHANGE_RENEWAL_PREF other than its UPGRADE subtype, Google SUBSCRIPTION_DEFERRED — a bare price change announces nothing), and an effective delivery carrying data.phase = "effective" on Apple, Google and Stripe alike, when the product on the purchase actually moved. See plan-change phases below. |
subscription.paused | A subscriber pauses their subscription (Google SUBSCRIPTION_PAUSED, Stripe paused subscription). Apple has no pause. |
subscription.resumed | A paused subscription grants access again (Google restart/recovery from pause, Stripe pause end). Google and Stripe — Apple has no pause to resume from. |
subscription.recovered | A billing failure resolves and the subscription grants access again (Google SUBSCRIPTION_RECOVERED; on Apple and Stripe, any store write that moves a purchase out of BILLING_ISSUE into an access-granting state). All three stores — see the coverage note below. |
subscription.revoked | Access ends immediately rather than lapsing at end of term — a refund, a family-sharing removal, a store-initiated revocation (Apple REVOKE, Google SUBSCRIPTION_REVOKED), or a Stripe charge refund that ends access mid-term. Distinct from subscription.expired, which is an end-of-term expiry. |
subscription.offer_redeemed | A subscriber redeems a promotional offer, an offer code, or a win-back offer (Apple OFFER_REDEEMED). Apple only — see the coverage note below. Distinct from subscription.uncancelled (the subscription may have fully lapsed) and from subscription.product_changed (the redeemed offer is often for the same product). |
credit.ledger.appended | A row is appended to the append-only credit ledger (a grant or a spend). |
funnel.session.started | A funnel session begins — web (a visitor opens a funnel) or native (a device presents a funnel). |
funnel.session.advanced | A session arrives at a page, moving forward — web or native. A native session's first screen fires this too (there is no separate native "arrived at the opening screen" event, so its advanced count includes the first screen). |
funnel.session.paid | A purchase completes inside a web funnel. Web only today — carries no amount; see revenue.* above for money. |
funnel.session.completed | A session reaches the funnel's end — web or native. |
funnel.session.abandoned | A web session times out server-side with no further activity. Web only today — a native session has no equivalent server-side timeout; see dismissed below for how a device close is reported instead. |
funnel.session.dismissed | The user closes the funnel on the device. Native only — a device close is a different meaning from the web abandoned timeout above, so it is never sent for a web session. |
funnel.session.custom_action | An author-defined action fires inside a funnel page. Web only today. |
You choose which of these keys are delivered to a given endpoint when you configure the connection. Only the Custom Webhook, Slack, and Discord integrations offer the full catalog above, funnel events included — other, more narrowly-scoped providers (Meta CAPI, TikTok Events, analytics/attribution providers) do not. How a funnel session becomes these keys — device events versus web session events, the outbox → Redpanda → ClickHouse pipeline, and the dashboard Sessions tab — lives on Events & Analytics.
One-time purchase revenue keys
Before 2026-09-04, every one-time (non-subscription) purchase — a consumable
or non-consumable in-app purchase on Apple or Google — was recorded with the
same type as a new subscription, revenue.INITIAL. Stripe funnel purchases
of a one-time package recorded no revenue event at all. Both are fixed: a
one-time purchase is now typed by what it actually is, and it reaches the
Stripe funnel path too.
The type is decided by one rule, in one place
(services/revenue/one-time-type.ts), keyed only on the product's
ProductType:
ProductType | Revenue type |
|---|---|
CONSUMABLE | revenue.CREDIT_PURCHASE |
NON_CONSUMABLE | revenue.NON_RENEWING_PURCHASE |
SUBSCRIPTION | unchanged — the caller's own INITIAL / RENEWAL / TRIAL_CONVERSION classification stands |
Producer table — which store, and which code path, writes each of these two keys:
| Revenue key | Apple | Stripe (funnel) | CSV import | |
|---|---|---|---|---|
revenue.CREDIT_PURCHASE | Consumable IAP, receipt verification | Consumable one-time product, receipt verification | One-time purchase of a consumable package | A historical consumable purchase, on import |
revenue.NON_RENEWING_PURCHASE | Non-consumable IAP, receipt verification | Non-consumable one-time product, receipt verification | One-time purchase of a non-consumable package | A historical non-consumable purchase, on import |
All four producers — Apple and Google receipt verification, the Stripe funnel's one-time purchase completion, and the RevenueCat/Adapty CSV importer — call the same rule above, so a purchase is typed identically regardless of which path recorded it.
revenue.CREDIT_PURCHASE means "a consumable IAP was bought," not "a
credit grant was recorded." A CONSUMABLE product configured with no
currency-grant rows behind it grants nothing at all — a project
misconfiguration — and is still filed as a credit purchase. The type is a
property of the product, not of what the purchase happened to grant.
History is not rewritten. A one-time purchase recorded before
2026-09-04 keeps revenue.INITIAL — it is not retyped to
revenue.CREDIT_PURCHASE or revenue.NON_RENEWING_PURCHASE. If you're
reconciling historical numbers against numbers recorded after this date,
older one-time purchases will only ever appear under revenue.INITIAL.
revenue.REACTIVATION fires on all three stores and carries two
different economic meanings under one key: a lapsed subscriber returning
(a win-back offer redemption or a plain resubscribe), and an accounting
reversal — Apple un-does a refund that had already been applied. Only
Apple produces the reversal case; every Google and Stripe
revenue.REACTIVATION is the win-back case. (A billing-issue recovery is
not a reactivation: it is billed as the retry it is — a revenue.RENEWAL
or revenue.TRIAL_CONVERSION — with subscription.recovered beside it.)
Internally, the underlying revenue event is tagged
metadata.reason: "refund_reversed" (the exported
REACTIVATION_REVERSAL_REASON constant in
apps/api/src/services/apple/apple-webhook.ts) for the reversal case only;
an unmarked one is the win-back case. This tag reaches the delivered
Custom Webhook payload as data.reason — a reversal's data carries
"reason": "refund_reversed" in addition to the fixed shape described
above; a win-back's data omits the reason key entirely
(never reason: null), so a Custom Webhook receiver can tell the two
revenue.REACTIVATION cases apart from the delivered payload alone.
Delivery change for existing connections. Connections that already
receive revenue.INITIAL were widened by migration 0123 to also receive
revenue.CREDIT_PURCHASE and revenue.NON_RENEWING_PURCHASE — no action
is needed to keep receiving purchases you were already being told about.
revenue.REACTIVATION is opt-in: it is a genuinely new signal, so it is
not added to any existing connection automatically.
This also changes the vendor event name an existing connection sends for a
one-time purchase, because the vendor mapping is keyed on the revenue type,
not on "a purchase happened." On META_CAPI, a consumable IAP that used
to deliver as Subscribe (via revenue.INITIAL) now delivers as
Purchase (via revenue.CREDIT_PURCHASE); on TIKTOK_EVENTS,
Subscribe becomes CompletePayment. Non-consumable one-time purchases
move the same way, from Subscribe/Subscribe to Purchase/
CompletePayment respectively.
Why: a purchase that never becomes a subscription must not be reported to
an ad platform as a subscription — doing so corrupts the platform's
optimisation, which learns from what it's told converts. The consequence
an advertiser will actually notice: conversion volume on a campaign
optimised for Subscribe events drops on the day this ships, because
one-time purchases that used to count toward it now report as Purchase/
CompletePayment instead.
Per-store coverage
Every lifecycle key below fires on all three stores except the two pause keys
and subscription.offer_redeemed:
| Event | Apple | Stripe | |
|---|---|---|---|
subscription.trial.started | ✅ | ✅ | ✅ |
subscription.cancel_requested | ✅ | ✅ | ✅ |
subscription.expired | ✅ | ✅ | ✅ |
subscription.billing_issue | ✅ | ✅ | ✅ |
subscription.grace_period | ✅ | ✅ | ✅ |
subscription.recovered | ✅ | ✅ | ✅ |
subscription.revoked | ✅ | ✅ | ✅ |
subscription.product_changed | ✅ | ✅ | ✅ |
subscription.uncancelled | ✅ | ✅ | ✅ |
subscription.paused | — | ✅ | ✅ |
subscription.resumed | — | ✅ | ✅ |
subscription.offer_redeemed | ✅ | — | — |
subscription.recovered reaches all three stores, but by two different routes.
Google sends a native SUBSCRIPTION_RECOVERED notification. Apple and Stripe send no
recovery notification at all, so theirs is not read off an event type — it is read off
the purchase's own prior state: the handler captures the status under the same row lock
it writes with, and emits the key only when that prior status was BILLING_ISSUE and
the incoming one grants access. This is not the guess it replaced. An ordinary renewal
of an already-active subscription has no BILLING_ISSUE prior state, so it cannot
produce a false recovery. A dunning flow that ends on subscription.recovered now
works on iOS and web as well as Android.
subscription.grace_period is no longer Google-only. Apple sends
DID_FAIL_TO_RENEW with subtype GRACE_PERIOD when the app has a billing grace
period configured, and that half announces grace_period rather than
billing_issue — because the subscriber still has entitlement, exactly as in the
Google case. Apple deliveries without that subtype, and GRACE_PERIOD_EXPIRED, stay
billing_issue. Stripe announces grace_period while an invoice is past_due
and smart retries are running; once Stripe stops covering it (unpaid,
incomplete), the key is billing_issue.
subscription.paused / subscription.resumed have no Apple row because Apple
has no pause: there is nothing to pause and nothing to resume from. Google
pauses natively; Stripe pauses by subscription status.
subscription.offer_redeemed is the mirror case and Apple-only for the same
reason. Apple has a dedicated OFFER_REDEEMED notification type; Google
delivers an offer redemption as an ordinary purchase/recovery notification with
the offer named inside the purchase resource rather than as its own event type,
and Stripe has no equivalent event at all. If you rely on
subscription.offer_redeemed to stop a win-back campaign, that campaign is
iOS-only today.
Plan-change phases
One real plan change can deliver subscription.product_changed twice, and the
phase field inside data is how you tell the two deliveries apart:
- No
phasefield — the announcement. The store says a change is coming. The product may not have moved at all (a price change on the same product is one of these), and for a scheduled downgrade it will not move for weeks. CarriesstoreEventTypeandwebhookEventId. A missingphasemeans "announcement", not "unknown" — the field is never retrofitted onto this producer. phase="effective"— the change has taken effect. Emitted from inside the purchase write itself, only when the product on the purchase actually moved. CarriespreviousProductId,productIdandchangeType. Apple, Google and Stripe all produce it.
Google's deferred-change flow is the case that genuinely produces both: an
announcement at deferral, then an effective delivery at the renewal that applies it.
They are separate events with distinct ids, so webhook-id dedup does not collapse
them. Apple's UPGRADE subtype is the opposite case: Apple has already charged the
subscriber and applied the new tier, so announcing a change as coming would be
false — no announcement is sent for it and you receive only the effective delivery.
Billing-state consumers should act on phase = "effective"; "your plan changes on
the 14th" notifications want the announcement.
Zero-value charges
A revenue envelope whose type grants (any of INITIAL, TRIAL_CONVERSION,
REACTIVATION, RENEWAL and the one-time types) but whose amount is 0 — a
trial start, a comp, a $0 lead-in or a $0 renewal — reaches only the
customer's own endpoints and chat channels: Custom Webhook, Slack and
Discord. Ad and attribution providers never see a $0 "Subscribe": they get
the fact through subscription.trial.started (StartTrial) instead. If you
consume webhooks through an analytics CRM (Amplitude, Mixpanel, Braze), the
$0 rows are not delivered there; subscription.* still is.
CSV import is history
Rows imported from a RevenueCat/Adapty CSV export reach analytics
(ClickHouse ingests every revenue message that carries a revenue id) but
never reach integrations and never produce a project-webhook (v1) delivery:
an import backfills the past, it does not announce the present. Imported
rows grant no credits either. A refunded source row still records its charge
plus its REFUND row, so imported gross and net match what the live path
books; subscription charges are labelled by the same cross-store rule —
TRIAL_CONVERSION for the first paid row after a trial row,
REACTIVATION for a paid row whose predecessor lapsed.
4. Verifying signatures
Every delivery carries both a webhook-* and a Svix-compatible svix-* alias for the same three headers, so you can use whichever your HTTP library or an existing Svix SDK expects:
| Header | Alias | Description |
|---|---|---|
webhook-id | svix-id | The event id — same value as the payload's id field. |
webhook-timestamp | svix-timestamp | Unix seconds (as a string) when the request was signed. |
webhook-signature | svix-signature | One or more space-separated v1,<base64> signatures — see rotation below. |
Signature scheme
toSign = `${webhook-id}.${webhook-timestamp}.${rawRequestBody}`
secret = base64decode(whsecValue without the "whsec_" prefix)
signature = base64( HMAC-SHA256(secret, toSign) )
header = `v1,${signature}`Notes:
rawRequestBodyis the exact bytes received on the wire — parse it for verification only after checking the signature, since re-serializing JSON can change byte-for-byte content.- During a secret rotation,
webhook-signature(andsvix-signature) carry onev1,...candidate per currently active secret, space-separated. Verification passes if any candidate matches — try each one. - Reject requests whose
webhook-timestampis too far from your own clock (5 minutes is a reasonable tolerance) to defend against replay of an intercepted request.
Node.js
const crypto = require("node:crypto");
function verifyRovenueWebhook({ id, timestamp, signatureHeader, rawBody, secrets, toleranceSeconds = 300 }) {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) {
throw new Error("timestamp outside tolerance");
}
const toSign = `${id}.${timestamp}.${rawBody}`;
const candidates = signatureHeader.split(" ");
for (const secret of secrets) {
const encoded = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
const key = Buffer.from(encoded, "base64");
const expected = crypto.createHmac("sha256", key).update(toSign).digest();
for (const part of candidates) {
const [version, sig] = part.split(",");
if (version !== "v1" || !sig) continue;
const candidate = Buffer.from(sig, "base64");
if (candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected)) {
return true;
}
}
}
throw new Error("no matching signature");
}
module.exports = { verifyRovenueWebhook };Pass every secret you currently have on file for the endpoint (typically one, or two during a rotation window) as the secrets array.
Python
import hashlib
import hmac
import base64
import time
def verify_rovenue_webhook(id_, timestamp, signature_header, raw_body, secrets, tolerance_seconds=300):
now = int(time.time())
if abs(now - int(timestamp)) > tolerance_seconds:
raise ValueError("timestamp outside tolerance")
to_sign = f"{id_}.{timestamp}.{raw_body}".encode()
candidates = signature_header.split(" ")
for secret in secrets:
encoded = secret[len("whsec_"):] if secret.startswith("whsec_") else secret
key = base64.b64decode(encoded)
expected = hmac.new(key, to_sign, hashlib.sha256).digest()
for part in candidates:
version, _, sig = part.partition(",")
if version != "v1" or not sig:
continue
candidate = base64.b64decode(sig)
if hmac.compare_digest(candidate, expected):
return True
raise ValueError("no matching signature")5. Secret rotation
- Rotating an endpoint's secret mints a brand-new
whsec_...value and returns it once, the same as at creation. - The previous secret stays valid for 24 hours after rotation. During that grace window,
webhook-signaturecontains av1,...entry signed with each active secret, so a receiver that hasn't picked up the new value yet can still verify. - After 24 hours the old secret is dropped from the header entirely — update your stored secret before then.
6. Delivery, retries & timeouts
- Delivery is at-least-once — always design your handler to be safe to run more than once for the same event (see idempotency).
- Each attempt has a 15-second wall-clock cap covering connect, headers and the whole response body; anything slower is aborted, treated as a failed attempt and retried per the schedule below. That includes a 2xx whose body is still arriving when the cap hits: the attempt counts as failed and the event is delivered again, which is one more reason handlers must be idempotent.
- 2xx — delivery is marked successful, no retry.
- 3xx — redirects are never followed; treated as a permanent failure (no retry).
- 408, 425, 429 — treated as "try again": retried.
- Other 4xx — treated as a permanent rejection: sent straight to the dead-letter state, no further attempts.
- 5xx (or any other unexpected status) — retried.
Retry schedule
Up to 8 attempts total (the first attempt plus 7 retries), spread over roughly 31 hours:
| Attempt | Wait since previous attempt | Elapsed since first attempt |
|---|---|---|
| 1 | — | 0s |
| 2 | 30s | 30s |
| 3 | 2m | 2m 30s |
| 4 | 10m | 12m 30s |
| 5 | 1h | 1h 12m 30s |
| 6 | 6h | 7h 12m 30s |
| 7 | 12h | 19h 12m 30s |
| 8 | 12h | ≈31h 12m 30s |
If attempt 8 still fails (or a non-retriable status is returned earlier), the delivery moves to a dead-letter state and is shown as failed in the Delivery Log. You can manually redeliver it from there at any time — redelivery is not limited by the retry schedule, only by how long the originating event is retained.
Redelivery window: roughly 3 days from the time of the event. Past that the originating event is pruned and redelivery returns a 410 — the delivery row stays in the log as a record, but there is nothing left to replay. That window is deliberately more than a day longer than the ≈31-hour retry schedule above, so a fully-exhausted dead-letter is always still redeliverable when you find it.
7. Idempotency & at-least-once delivery
Dedupe on the webhook-id header (equivalently, the payload's id field): store it once you've successfully processed a delivery, and skip any delivery whose id you've already seen.
webhook-id is stable for a given underlying event — it does not change across automatic retries, and it also does not change when you manually redeliver that same past event from the Delivery Log, since redelivery replays the original stored event rather than minting a new one. A manual redeliver should therefore be treated by your handler exactly like a very late retry of the same event, not as a new occurrence.
This also holds for a web funnel event: its id is stable across an automatic retry, a manual redeliver, and a replay of the underlying event stream, and it is the same value that identifies the event internally, so it can be used to join a webhook delivery back to other data about the same event.
Manual redeliver of a native (device) funnel event does not currently work. Clicking redeliver on a funnel.session.started / .advanced / .completed / .dismissed delivery that originated from a device returns a 410 ("event expired") even when the event is well within the redelivery window — a known, parked gap in how the Delivery Log looks up the originating event for that one aggregate. Automatic retries of a still-in-flight native delivery are unaffected; only the manual redeliver action on an already-completed or dead-lettered native delivery is broken. Redelivering a web-originated funnel event works as described above.
8. Endpoint requirements
- The URL must use
https://. Plainhttp://is only accepted against a non-production Rovenue deployment (self-hosted local/dev), never in production. - The URL may not embed userinfo (
https://user:pass@host/...). - The target host must resolve to a publicly routable address — loopback, private (RFC 1918), link-local (including the
169.254.169.254cloud metadata address), unique-local, and carrier-grade-NAT ranges are all rejected, both at save time and at delivery time (so a hostname that starts resolving privately after being saved still gets blocked).
Footnotes
-
Defined in the catalog and selectable on a connection, but not yet emitted by the backend — coming with SDK identify instrumentation. ↩
Discord
Post real-time Rovenue revenue, subscription, funnel-session, and credit-ledger events to a Discord channel via an incoming webhook.
Subscriber Identity Attributes
Reserved subscriber attributes for third-party vendor ids (AppsFlyer, Adjust, Firebase, Mixpanel, Amplitude, OneSignal, Braze, Iterable, Airbridge, Singular) used to resolve identity across outbound integrations.