Rovenue SDK
Integrations

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

  1. In the Rovenue dashboard, navigate to Apps (left sidebar).
  2. Find Custom Webhook in the integrations grid and click it.
  3. Enter your endpoint URL (must be https:// in production) and choose which events to receive.
  4. 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).
  5. 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": { }
}
FieldDescription
idThe originating event's stable identifier. Use this for idempotency — see below for how it behaves on retries and manual redeliver.
typeOne of the event keys below.
createdISO-8601 timestamp of when the underlying event occurred (not when it was delivered).
apiVersionA date-versioned string identifying the payload schema, currently 2026-08-24. Bump only accompanies a documented breaking change to the envelope or data shape.
projectIdThe Rovenue project the event belongs to.
dataEvent-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"
}
  • amount is a decimal string in the major currency unit (e.g. "9.99", not cents).
  • currency is the ISO 4217 code.
  • externalId is the externalId you passed to the SDK's identify call, if any — no other PII (email, phone, IP, etc.) is ever included in a webhook payload.
  • reason is present only on some revenue.REACTIVATION events — see one-time purchase revenue keys below. Every other revenue event omits the key entirely rather than sending it as null.

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 "":

web — funnel.session.started
{
  "sessionId": "cksession1a2b3c4d5e6f7g8h",
  "funnelId": "ckfunnel1a2b3c4d5e6f7g8h9i",
  "versionId": "ckversion1a2b3c4d5e6f7g8h",
  "target": "web",
  "pageId": "welcome"
}
native (device) — funnel.session.advanced
{
  "funnelId": "ckfunnel1a2b3c4d5e6f7g8h9i",
  "revision": 2,
  "target": "native",
  "pageId": "onboarding-2",
  "placementId": "ckplacement1a2b3c4d5e6f7g",
  "variantId": "ckvariant1a2b3c4d5e6f7g8h",
  "experimentKey": "onboarding-copy-test"
}
web — funnel.session.custom_action
{
  "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_KEYS in packages/shared/src/integrations.ts — that file is the source of truth if this list and the code ever drift.

Event keyFires when
revenue.INITIALThe 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_CONVERSIONA trial converts to a paid subscription.
revenue.RENEWALA recurring subscription renews for another billing period.
revenue.CREDIT_PURCHASEA 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_PURCHASEA 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.REACTIVATIONA 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.REFUNDA refund is recorded (data.amount is always a positive value).
revenue.CANCELLATIONThe subscription ended — the $0 marker written when a subscription reaches its end of term. It books no money and grants no credits.
subscription.trial.startedA new trial period begins.
subscriber.identified 1An SDK identify() call binds a Rovenue subscriber to your app-user id.
subscription.cancel_requestedA subscriber requests cancellation (the subscription may still be active until period end).
subscription.expiredA subscription reaches the EXPIRED state.
subscription.billing_issueA 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_periodA 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.uncancelledA 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_changedA 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.pausedA subscriber pauses their subscription (Google SUBSCRIPTION_PAUSED, Stripe paused subscription). Apple has no pause.
subscription.resumedA paused subscription grants access again (Google restart/recovery from pause, Stripe pause end). Google and Stripe — Apple has no pause to resume from.
subscription.recoveredA 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.revokedAccess 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_redeemedA 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.appendedA row is appended to the append-only credit ledger (a grant or a spend).
funnel.session.startedA funnel session begins — web (a visitor opens a funnel) or native (a device presents a funnel).
funnel.session.advancedA 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.paidA purchase completes inside a web funnel. Web only today — carries no amount; see revenue.* above for money.
funnel.session.completedA session reaches the funnel's end — web or native.
funnel.session.abandonedA 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.dismissedThe 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_actionAn 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:

ProductTypeRevenue type
CONSUMABLErevenue.CREDIT_PURCHASE
NON_CONSUMABLErevenue.NON_RENEWING_PURCHASE
SUBSCRIPTIONunchanged — 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 keyAppleGoogleStripe (funnel)CSV import
revenue.CREDIT_PURCHASEConsumable IAP, receipt verificationConsumable one-time product, receipt verificationOne-time purchase of a consumable packageA historical consumable purchase, on import
revenue.NON_RENEWING_PURCHASENon-consumable IAP, receipt verificationNon-consumable one-time product, receipt verificationOne-time purchase of a non-consumable packageA 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:

EventAppleGoogleStripe
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 phase field — 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. Carries storeEventType and webhookEventId. A missing phase means "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. Carries previousProductId, productId and changeType. 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:

HeaderAliasDescription
webhook-idsvix-idThe event id — same value as the payload's id field.
webhook-timestampsvix-timestampUnix seconds (as a string) when the request was signed.
webhook-signaturesvix-signatureOne 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:

  • rawRequestBody is 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 (and svix-signature) carry one v1,... candidate per currently active secret, space-separated. Verification passes if any candidate matches — try each one.
  • Reject requests whose webhook-timestamp is 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-signature contains a v1,... 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:

AttemptWait since previous attemptElapsed since first attempt
1—0s
230s30s
32m2m 30s
410m12m 30s
51h1h 12m 30s
66h7h 12m 30s
712h19h 12m 30s
812h≈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://. Plain http:// 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.254 cloud 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

  1. Defined in the catalog and selectable on a connection, but not yet emitted by the backend — coming with SDK identify instrumentation. ↩

On this page