Rovenue SDK
Reference

API Errors

Every error code the API can return, documented by its exact wire value.

Every Rovenue API response is either { data: T } or { error: { code, message } }. This page documents every value error.code can take — the exact string returned on the wire, alongside the HTTP status it ships with, what it means, and what to do about it.

Looking for SDK errors instead — the RovenueError/RovenueException kind thrown by the React Native, Swift, and Kotlin SDKs? See SDK Errors; this page is the API's { error: { code, message } } envelope, a different (and larger) set aimed at anyone calling the HTTP API directly rather than through an SDK.

Five codes are lowercase on the wire even though their name in the API's source is SCREAMING_CASE: asset_in_use, asset_missing, purchase_not_paid, apple_offer_signing_unavailable, apple_offer_signing_failed. Match on the exact string in the table below — not an uppercased guess.


Quick reference

CodeHTTP status
HTTP_ERROR500
VALIDATION_ERROR400
INTERNAL_ERROR500
UNAUTHORIZED401
FORBIDDEN403
NOT_FOUND404
CONFLICT409
NOT_IMPLEMENTED501
RATE_LIMITED429
ROVI_NOT_CONFIGURED412
ROVI_QUOTA_EXCEEDED429
BEARER_REQUIRED401
INVALID_API_KEY401
INVALID_API_KEY_FORMAT401
API_KEY_KIND_MISMATCH403
ORIGIN_NOT_ALLOWED403
STORE_NOT_CONFIGURED400
RECEIPT_INVALID400
RECEIPT_PRODUCT_UNKNOWN404
RECEIPT_IN_USE409
STORE_API_ERROR502
STORE_UNAVAILABLE503
STORE_CREDENTIALS_INVALID400
STORE_KEY_MISSING400
STORE_KEY_WRONG_TYPE400
STORE_NOTIFICATION_URL_IN_USE409
STORE_KEY_INSUFFICIENT_ROLE403
STORE_NOTIFICATION_URL_NOT_PUBLIC400
STORE_NOTIFICATION_URL_REJECTED400
APPLE_APP_ID_MISSING503
APPLE_APP_ID_MISMATCH401
NOTIFICATION_FORWARD_LIMIT409
PUBSUB_API_DISABLED400
PUBSUB_PERMISSION_MISSING400
PUBSUB_ORG_POLICY400
PUBSUB_TOPIC_NOT_FOUND400
PUBSUB_ENDPOINT_NOT_HTTPS400
PUBSUB_CONNECT_IN_PROGRESS409
WEBHOOK_IN_PROGRESS409
STORE_TEST_NOTIFICATION_TOKEN_UNKNOWN400
STORE_ID_TAKEN409
STRIPE_PRICE_NOT_FOUND400
STORE_CANCEL_UNSUPPORTED409
REVOKE_IMMEDIATELY_UNSUPPORTED400
apple_offer_signing_unavailable400
apple_offer_signing_failed400
FONT_FORMAT_UNSUPPORTED400
FONT_FILE_TOO_LARGE400
FONT_QUOTA_EXCEEDED400
FONT_FAMILY_NOT_FOUND404
ASSET_FORMAT_UNSUPPORTED400
ASSET_FILE_TOO_LARGE413
ASSET_QUOTA_EXCEEDED402
ASSET_STORAGE_UNAVAILABLE503
ANALYTICS_UNAVAILABLE503
ASSET_INVALID_NAME400
ASSET_PROCESSING_FAILED400
IMPORT_FILE_TOO_LARGE413
IMPORT_STORAGE_UNAVAILABLE503
FEATURE_NOT_CONFIGURED503
DATABASE_UNAVAILABLE503
CACHE_UNAVAILABLE503
QUEUE_UNAVAILABLE503
ANALYTICS_UNREACHABLE503
purchase_not_paid400
SUBSCRIBER_UNRESOLVABLE409
PAYLOAD_TOO_LARGE413
STRIPE_NOT_CONNECTED503
STRIPE_CUSTOMER_NOT_FOUND404
STORE_LOOKUP_UNAVAILABLE503
RETURN_URL_NOT_ALLOWED400
WRONG_PAGE409
STEP_STALE409
SESSION_CLOSED409
OFFERING_IN_USE409
PRODUCT_IN_USE409
PRODUCT_HAS_HISTORY409
ACCESS_IN_USE409
CUSTOM_HOST_PATH_NOT_FOUND404
FUNNEL_VALIDATION400
FUNNEL_SETTINGS_INVALID400
FUNNEL_LOCALE_MISMATCH400
FIRST_API_KEY_EXISTS409
FUNNEL_SLUG_TAKEN409
FUNNEL_TARGET_CHANGED409
FUNNEL_ALREADY_ON_TARGET400
FUNNEL_CONVERSION_BLOCKED400
FUNNEL_CONVERSION_UNPUBLISHES409
FUNNEL_IN_USE409
FUNNEL_ARCHIVED409
FUNNEL_DRAFT_SUPERSEDED409
FUNNEL_SLUG_RESERVED400
FUNNEL_NOT_NATIVE400
NO_PUBLISHED_VERSION404
INVALID_ROW_REF400
audience_in_use409
CUSTOM_DOMAIN_LIMIT_REACHED409
HOSTNAME_TAKEN409
ROOT_FUNNEL_INVALID400
PAYMENT_ALREADY_RECORDED409
PAYMENT_TEMPORARILY_UNAVAILABLE503
PAYMENT_IN_FLIGHT409
PAYMENT_NOT_SETTLED_YET409
PAYMENT_PROVIDER_REJECTED422
PAYMENT_UNVERIFIABLE409
CONFIRM_EMAIL_MISMATCH400
OWNS_PROJECTS409
exposure_out_of_range400
EXPERIMENT_NOT_RUNNING409
promote_requires_flag_experiment400
window_too_large400
USAGE_LIMIT_EXCEEDED403
BILLING_CARD_IN_USE409
BILLING_CARD_OPEN_INVOICE409
WEBHOOK_EVENT_EXPIRED410
WEBHOOK_EVENT_UNMAPPABLE422
STALE_BATCH422
TELEMETRY_UNAVAILABLE503
IDENTIFIER_TAKEN409
UNKNOWN_REFERENCES400
IMPORT_JOB_STATE409
IMPORT_MAPPING_INCOMPLETE400
EXPERIMENT_STATE400
WEBHOOK_STATE400
COPILOT_INTENT_SETTLED409
ROLE_REQUIRED403
CAPABILITY_REQUIRED403
BILLING_NOTHING_TO_CANCEL409
BILLING_ALREADY_ACTIVE409
BILLING_UPGRADE_REQUIRED409
STRIPE_ALREADY_CONNECTED409
ALREADY_MEMBER409
ALREADY_OWNER409
INVITATION_NOT_SENDABLE409
INVITATION_ALREADY_ACCEPTED409
INVITATION_EMAIL_MISMATCH403
WEBHOOK_ENDPOINT_LIMIT409
WEBHOOK_URL_REJECTED400
TEST_DELIVERY_NOT_REDELIVERABLE409
INTEGRATION_ALREADY_CONNECTED409
INTEGRATION_CREDENTIALS_REJECTED400
SCHEDULED_ACTION_CONFLICT409
HOSTNAME_INVALID400
ORIGIN_INVALID400
QUERY_REJECTED400
CURRENCY_GRANT_INVALID400
REFUND_NOT_POSSIBLE422
LEGAL_URL_IN_USE409
ADMIN_EMAIL_DOMAIN403
ADMIN_2FA_REQUIRED403
ADMIN_SESSION_EXPIRED401
PROJECT_SUSPENDED403
SUPPORT_ACCESS_READ_ONLY403

Codes

HTTP_ERROR

HTTP status: 500

mapHttpStatus in middleware/error.ts returns this for EVERY HTTPException whose status is not one of the explicitly mapped ones (400/401/403/404/409/429/501, which each get their own specific code) and whose cause isn't a recognized ERROR_CODE. A repo-wide scan of such call sites finds it at status 410, 412, 413 and 422. Never at 5xx: every 5xx HTTPException carries a typed cause (ops-resilience D9; the stuck census counts any that does not) — INTERNAL_ERROR, STORE_API_ERROR, FEATURE_NOT_CONFIGURED, STORE_UNAVAILABLE, DATABASE_UNAVAILABLE, CACHE_UNAVAILABLE, QUEUE_UNAVAILABLE, ANALYTICS_UNAVAILABLE, ANALYTICS_UNREACHABLE, …. This code is therefore NOT a signal of any single failure class or status the way every other code in this catalog is — it is what's left over among 4xx. A bare 409 is no longer part of it: it arrives as CONFLICT.

What to do: Do not branch on this code alone — it groups together the leftover 4xx statuses (preconditions, payload-size rejections, unprocessable requests) that all happen to route through an HTTPException without a typed cause; it is never a 5xx. Read the actual HTTP status and message on the response to know what really happened; if you need to react programmatically to a specific one of these cases, ask for it to be promoted to its own typed code (give the throw site a cause: ERROR_CODE.<NEW_CODE>) rather than parsing message.


VALIDATION_ERROR

HTTP status: 400

The default for a rejected HTTPException(400, ...) and for any unhandled ZodError (body/query/param schema failure) — by far the most common error code in the API. (The refund routes' 'cannot refund this purchase' answers are REFUND_NOT_POSSIBLE, not this code.)

What to do: Fix the request: re-check required fields, types, and enum values against the route's schema in message. This is a client-side mistake, not a transient failure — retrying unchanged will fail again.


INTERNAL_ERROR

HTTP status: 500

The final fallback in middleware/error.ts for any thrown value that is neither an HTTPException nor a ZodError — an unexpected bug, not a modeled failure mode. The real error is logged server-side; the client only ever sees a generic message. Every explicit HTTPException(500, …) (an invariant the server found broken, e.g. dashboard/invitations.ts's 'just-created invitation vanished' check) carries this code as its cause, so every 500 arrives as INTERNAL_ERROR. An infra outage never does: a database, cache, queue or analytics connection failure answers 503 with its own code.

What to do: Retry with backoff once (some causes are transient — a dropped DB connection, an unhandled edge case under load), then stop and report it if it persists. There is nothing the caller can do to avoid triggering this; it means the server hit a case it didn't expect. To catch every server-side failure, match on the HTTP status (>= 500): the 502 and 503 codes are separate entries.


UNAUTHORIZED

HTTP status: 401

The status→code mapping's answer for any HTTPException(401, ...) whose cause isn't a recognized ERROR_CODE. As of Task 5 (2026-09-06), the four api-key-auth.ts branches that used to fall through to this generic code now set their own cause (BEARER_REQUIRED, INVALID_API_KEY_FORMAT, INVALID_API_KEY), so this code no longer covers those. It remains the answer for any other 401 raised without a typed cause.

What to do: This is a generic 401 with no more specific code attached — read message for detail. If you expected BEARER_REQUIRED, INVALID_API_KEY_FORMAT or INVALID_API_KEY instead, check that you're hitting apiKeyAuth-guarded /v1 routes; other 401 producers in the codebase have not been given typed causes.


FORBIDDEN

HTTP status: 403

The status→code mapping's answer for any HTTPException(403, ...) without a recognized cause — the authenticated caller is real but not allowed to do this. As of Task 5 (2026-09-06), apiKeyAuth's own wrong-kind check now sets cause: API_KEY_KIND_MISMATCH instead of falling through here. This code still covers the separate route-level guards requireSecretKey/requirePublicApiKey (which run AFTER apiKeyAuth and have not been given a typed cause) and a dashboard user's role lacking the capability the route checks (assertProjectCapability/assertProjectAccess).

What to do: For SDK callers: switch to a secret key for server-to-server endpoints (credit grants and similar) — never ship a secret key in a client app. For dashboard callers: the signed-in user's project role doesn't include the needed capability; ask a project owner to grant it. If you expected API_KEY_KIND_MISMATCH instead, the rejection came from a route-level guard, not apiKeyAuth's own kind check.


NOT_FOUND

HTTP status: 404

Returned both by the generic status→code mapping for any bare HTTPException(404, ...) and directly by many routes for 'no row with that id in this project' (subscriptions, transactions, assets, font faces, projects). It never distinguishes 'never existed' from 'exists in a different project' — both look identical to avoid leaking cross-project existence.

What to do: Double-check the id and that it belongs to the project the request is authenticated against — a valid id for someone else's project 404s exactly like a nonexistent one. Don't treat this as retryable.


CONFLICT

HTTP status: 409

The status→code mapping's answer for any HTTPException(409, ...) whose cause isn't a recognized ERROR_CODE: the request collides with the resource's current state — an identifier already taken, a row still referenced by another, an operation already done (a refund already recorded). Every explained conflict carries its own code instead (IDENTIFIER_TAKEN, STORE_ID_TAKEN, FUNNEL_SLUG_TAKEN, OFFERING_IN_USE, IMPORT_JOB_STATE, …); a bare CONFLICT is left only where the server has nothing more specific to say, and each such site is listed with its reason in e2e/live/explained-refusals-allowlist.json.

What to do: Read message: it names what collided. Change the request (another identifier) or the state it collides with (remove the reference first), then retry. Retrying unchanged fails the same way.


NOT_IMPLEMENTED

HTTP status: 501

Reserved slot in the status→code mapping for an HTTPException(501, ...); at the time of writing no route in apps/api/src throws status 501, so this code has never actually been observed on the wire. It exists so a future not-yet-built endpoint stub has a machine-readable code to return instead of a bare 500.

What to do: If you see this in practice, treat it the same as a permanent client-side dead end — the endpoint exists in the route table but its implementation isn't live yet. Don't retry; check the changelog for when it ships.


RATE_LIMITED

HTTP status: 429

Thrown by the shared endpointRateLimit/apiKeyRateLimit middleware (middleware/rate-limit.ts) whenever a caller exceeds a route's per-minute cap — asset uploads and general API-key traffic are all gated this way. ROVI_QUOTA_EXCEEDED below is a distinct, unrelated 429 for a different limit (monthly AI usage, not request rate). From the shared limiter, error.details.retryAfterSeconds repeats the Retry-After wait.

What to do: Back off and retry after a short delay; the response should carry standard rate-limit headers to tell you how long. If this fires under normal usage, the per-minute cap for that route is probably too low for your traffic pattern — reduce polling frequency or batch requests.


ROVI_NOT_CONFIGURED

HTTP status: 412

The project has no working AI provider for Rovi copilot chat (it routes through resolveProviderForProject): no BYOK credentials saved for the project AND no server-wide default provider/API key configured in env either.

What to do: An operator must fix configuration: either save BYOK provider credentials for this project in the dashboard, or set ROVI_DEFAULT_PROVIDER/ROVI_DEFAULT_API_KEY (and friends) in the API's environment. A caller cannot work around this by retrying.


ROVI_QUOTA_EXCEEDED

HTTP status: 429

The project's monthly Rovi usage (message count or token count, whichever the tier caps first) has been exhausted, per evaluateQuota/resolveTier in middleware/rovi-quota-guard.ts. The response body carries tier, exceeded, and resetAt alongside the envelope for a client to show a real countdown.

What to do: Wait until resetAt (the first of next month, UTC) or upgrade the project's billing tier for a higher cap. Retrying before the reset always fails identically — this is not a transient rate limit.


BEARER_REQUIRED

HTTP status: 401

The Authorization header is missing or not a Bearer token. middleware/api-key-auth.ts's first check throws HTTPException(401, { message: 'Bearer token required', cause: ERROR_CODE.BEARER_REQUIRED }), so this is a distinct wire code from UNAUTHORIZED (as of Task 5, 2026-09-06).

What to do: Send the header as Authorization: Bearer <key> (public rov_pub_... or secret rov_sec_...). No key was presented at all, or it wasn't in Bearer scheme — nothing to rotate or look up in the dashboard yet.


INVALID_API_KEY

HTTP status: 401

The presented API key doesn't resolve to a live record — unknown, revoked, or expired. api-key-auth.ts's post-lookup check throws HTTPException(401, { message: 'Invalid or expired API key', cause: ERROR_CODE.INVALID_API_KEY }) (as of Task 5, 2026-09-06) once the key has the right prefix and kind but fails the DB lookup or the revoked/expired check.

What to do: Rotate the key in the dashboard if it's genuinely invalid, revoked, or expired. If the key looks right, double-check it belongs to the project you think it does — a key for a different project also resolves to nothing here.


INVALID_API_KEY_FORMAT

HTTP status: 401

The Bearer token doesn't match either recognized key prefix (rov_pub_/rov_sec_, via detectKind). api-key-auth.ts throws HTTPException(401, { message: 'Invalid API key format', cause: ERROR_CODE.INVALID_API_KEY_FORMAT }) (as of Task 5, 2026-09-06) before ever attempting a DB lookup.

What to do: Verify the key was copied in full and unmodified from the dashboard — a truncated or hand-edited key, or a credential from a different product, is the usual cause of a format mismatch.


API_KEY_KIND_MISMATCH

HTTP status: 403

A public key was presented where a secret key is required, or vice versa. api-key-auth.ts's required !== detected branch throws HTTPException(403, { message: '<kind> API key required', cause: ERROR_CODE.API_KEY_KIND_MISMATCH }) (as of Task 5, 2026-09-06). NOTE: the separate route-level guards requireSecretKey/requirePublicApiKey (which run after apiKeyAuth, for routes that share one middleware stack across both key kinds) have NOT been given a typed cause and still fall through to the generic FORBIDDEN — see that entry. error.details.required names the key kind the route needs.

What to do: Use a secret key for server-to-server endpoints (never ship one in a client app) or a public key for SDK/client endpoints. Read message for which kind this specific route required.


ORIGIN_NOT_ALLOWED

HTTP status: 403

A browser request on /v1/web/:publicKey came from an Origin that is not on this public key's allowed origins. Checked after authentication on every request (the preflight is answered for any origin), so the browser can read this error instead of a CORS failure. error.details.origin is the refused Origin.

What to do: Add the page's exact origin (scheme, host and port — e.g. https://app.example.com or http://localhost:5173) to the key's allowed origins in the dashboard (Project → API keys).


STORE_NOT_CONFIGURED

HTTP status: 400

A receipt verification (POST /v1/receipts/apple or /google) or the dashboard product-catalog lookup (getStoreCatalog) has no usable store credentials for the requested platform: no Apple credential row (a missing key is STORE_KEY_MISSING), or no Google Play service-account credentials saved for the project at all.

What to do: An operator must add the missing store credentials for this project in the dashboard (App Store Connect API key, or the Google Play service-account JSON) before the catalog can be fetched. Not retryable without that.


RECEIPT_INVALID

HTTP status: 400

POST /v1/receipts/apple or /google refused the receipt itself: the Apple JWS failed verification, the Google purchase token did not verify, the submitted productId does not match the verified transaction, or the Google one-time purchase was canceled.

What to do: Do not retry the same receipt. The purchase is not valid for this project; ask the user to retry the purchase or restore. If a legitimate purchase keeps failing, check that the app's bundle id or package name and the store credentials match the project.


RECEIPT_PRODUCT_UNKNOWN

HTTP status: 404

POST /v1/receipts/apple or /google verified (or was given) a product id that no product of this project carries, neither as its identifier nor as its Apple or Google store id. error.details.productId is the store product id the receipt names.

What to do: Add the product in the dashboard (Project → Products) with that store id, or fix the id the app purchases. Not retryable until the catalog changes.


RECEIPT_IN_USE

HTTP status: 409

POST /v1/receipts/apple or /google: the receipt is already bound to a different subscriber of this project, and the request does not carry the identity needed to move it.

What to do: Identify the user who owns the purchase (the same account that bought it) and resubmit, or transfer the subscriber through the secret-key /v1/subscribers/transfer route. Not retryable as is.


STORE_API_ERROR

HTTP status: 502

An upstream provider's API returned an error or an unusable answer: the store's own API (App Store Connect or Google Play Developer API) while listing products, fetching a subscription, or looking up a transaction; Stripe during the Connect authorization exchange, a funnel payment intent with no client secret, or a checkout session with no URL; or the project's AI provider rejecting the key in the copilot credential test. Credentials were valid enough to reach the provider, but the call itself failed (rate limit, transient outage, a malformed/unauthorized request the provider rejected).

What to do: Retry after a short delay — most causes are transient upstream issues. If it persists, check the store's own status page and verify the configured credentials still have the needed scopes/permissions (an API key that used to work can be revoked or downgraded on Apple/Google's side).


STORE_UNAVAILABLE

HTTP status: 503

A store could not be reached: its circuit breaker is open, or the call failed with a network error, a timeout, a 429 or a 5xx. It says nothing about the receipt or the payment itself — they may be perfectly valid. The response carries Retry-After: 60. Who answers it:

  • Google Play: POST /v1/receipts/google and POST /v1/subscribers/:appUserId/restore when a Play receipt cannot be checked with Play — instead of a 400 or an empty restore. A restore answers it whole when any of its receipts hit the outage, even if other receipts in it verified.
  • Stripe: the public web-funnel payment routes, POST /public/funnel-sessions/:sessionId/payment-intent ("Payments are temporarily unavailable…") and POST /public/funnel-sessions/:sessionId/confirm ("We can't confirm this payment right now…"). Stripe refusing the request is PAYMENT_PROVIDER_REJECTED or PAYMENT_UNVERIFIABLE, not this.
  • App Store receipts never answer it: POST /v1/receipts/apple and Apple receipts in a restore are signed transactions verified on the server without calling Apple. A bad one is a 400.
  • Any other route whose store call is rejected by an open circuit breaker.

What to do: Retry after the Retry-After seconds (60 — the breaker's reset window) and keep the purchase pending on the device; do not tell the user the purchase failed. A restore that answers this is retried whole: receipts it did verify are recorded idempotently, so verifying them again is safe.


STORE_CREDENTIALS_INVALID

HTTP status: 400

The App Store Connect or Google Play key cannot be read: the private key is not a PKCS#8 PEM of the right type (Apple: EC P-256; Google: RSA), the Apple key id is not 10 letters/digits, the issuer id is not a UUID, or only some of Apple's key id / issuer id / private key were given. PUT /dashboard/projects/:projectId/credentials/:store stores nothing; the store catalog and the connection test report it for a key stored before. error.details names the store (store: apple | google), the field (field: keyId | issuerId | privateKey | serviceAccount) and why (reason): missing (only some of Apple's key id / issuer id / private key were given; field is the first one missing), unreadable (not a PEM, or an id in the wrong format), wrong_key_type (a readable key of the wrong kind: Apple needs an EC P-256 .p8, Google an RSA key) rejected_by_store (the store refused a well-formed key), app_not_found (field: bundleId: App Store Connect has no app with the stored bundle id or app id that this key can see — the key itself authenticated) or insufficient_role (App Store Connect authenticated the key but its role cannot read the app: 403, answered by the key save, Re-check and the catalog; setting the notification URL answers STORE_KEY_INSUFFICIENT_ROLE instead). POST …/credentials/google/pubsub/connect and …/pubsub/rotate-token answer it with domain_scoped_project when the service account's project_id (field: serviceAccount) or the existingTopic's project (field: existingTopic) is a legacy domain-scoped Google Cloud project (example.com:my-project): managed Pub/Sub does not support those; set the push subscription up by hand. The setup state's app step also carries this code with unreadable (the stored credential cannot be decrypted) or stored_setup_invalid (it decrypts, but is not a setup this server can read).

What to do: Re-download the key (.p8 from App Store Connect → Users and Access → Integrations; the service-account JSON from Google Cloud) and paste it whole, with the key id and issuer id shown next to it.


STORE_KEY_MISSING

HTTP status: 400

The Apple key a feature needs is not stored for the project. details.key names it: inAppPurchase (App Store Server API calls, offer signing, test notifications) or appStoreConnect (product import and prices). Returned by POST /dashboard/projects/:projectId/credentials/apple/verify, POST …/credentials/apple/test-notification, GET …/credentials/apple/notification-url, POST …/credentials/apple/notification-url/apply (which needs both keys, and checks both before calling Apple) and the store catalog. A consumer never falls back to the other key.

What to do: Add the named key on the Stores page: an In-App Purchase key (App Store Connect → Users and Access → Integrations → In-App Purchase) or a Team App Store Connect API key (Integrations → App Store Connect API → Team Keys).


STORE_KEY_WRONG_TYPE

HTTP status: 400

PUT /dashboard/projects/:projectId/credentials/apple refused a key that Apple accepts on the OTHER API: a Team App Store Connect API key given as the In-App Purchase key, or the reverse. Nothing is stored. details.expected is the key type the field needs (inAppPurchase | appStoreConnect) and details.field the body field (inAppPurchaseKey | appStoreConnectKey).

What to do: Upload the right key: SubscriptionKey_<KEYID>.p8 from Integrations → In-App Purchase for the In-App Purchase key; AuthKey_<KEYID>.p8 from Integrations → App Store Connect API → Team Keys for the App Store Connect API key.


STORE_NOTIFICATION_URL_IN_USE

HTTP status: 409

POST /dashboard/projects/:projectId/credentials/apple/notification-url/apply with replaceExisting: false found an App Store Server Notifications URL that is not exactly this project's for Production or Sandbox (details.current: the URL per slot). Nothing was changed at Apple. details.otherRovenueProject is true when that URL is another Rovenue project's webhook on this API's own host (staging and production on one bundle, or a re-created project).

What to do: Replace it (that service stops receiving Apple's notifications), or keep it and have that service forward Apple's request body to Rovenue's notification URL.


STORE_KEY_INSUFFICIENT_ROLE

HTTP status: 403

App Store Connect refused to read or modify the app with this App Store Connect API key: its role is too low. Answered by GET /dashboard/projects/:projectId/credentials/apple/notification-url and POST …/notification-url/apply (details.key: appStoreConnect). The key save, Re-check and the catalog report the same 403 as STORE_CREDENTIALS_INVALID with reason: insufficient_role on the key's step instead.

What to do: Use a Team key with the App Manager or Admin role (Users and Access → Integrations → App Store Connect API).


STORE_NOTIFICATION_URL_NOT_PUBLIC

HTTP status: 400

POST /dashboard/projects/:projectId/credentials/apple/notification-url/apply, before any call to Apple: this server's own notification URL (details.url, built from PUBLIC_BASE_URL) is not a public https URL — plain http, localhost, a .local/.internal name or a loopback/private IP. Apple can never post to it, so nothing was sent to App Store Connect.

What to do: Set PUBLIC_BASE_URL to the API's public https address (behind your reverse proxy or tunnel) and restart the API, then try again. Not retryable until then.


STORE_NOTIFICATION_URL_REJECTED

HTTP status: 400

POST /dashboard/projects/:projectId/credentials/apple/notification-url/apply: App Store Connect refused to store this server's notification URL (its 409 on the app update). details.url is the URL sent; details.appleMessage is Apple's own error title and detail. The App Store Connect key is fine; nothing changed at Apple.

What to do: Read Apple's message: usually the URL is not one Apple accepts (it must be public https). Fix PUBLIC_BASE_URL, or paste the URL by hand in App Store Connect to see Apple's form validation.


APPLE_APP_ID_MISSING

HTTP status: 503

A Production-environment Apple notification (POST /webhooks/apple/:projectId) arrived for a project whose App Apple ID (Apple's numeric app id) is not stored, and neither the project's App Store pick, App Store Connect (the stored Team key) nor the public iTunes lookup knows it. Apple's verifier cannot check a Production notification without it, so nothing was processed; Apple redelivers the notification later. The Apple notifications step shows this reason. Sandbox notifications and StoreKit receipts (signed transactions, which carry no App Apple ID) never need it.

What to do: Enter the App Apple ID in the Apple app step (App Store Connect → the app → App Information → General Information → Apple ID), or store an App Store Connect API key so Rovenue can look it up.


APPLE_APP_ID_MISMATCH

HTTP status: 401

A Production-environment Apple notification (POST /webhooks/apple/:projectId) carried a valid Apple signature for this project's bundle id, but names another App Apple ID (Apple's numeric app id) than the one stored for the project (or resolved for it). Nothing was processed; Apple redelivers the notification later. The Apple notifications step shows this reason until a Production notification for the stored id arrives or the id is changed.

What to do: Check the App Apple ID in the Apple app step against App Store Connect → the app → App Information → General Information → Apple ID, and correct it.


NOTIFICATION_FORWARD_LIMIT

HTTP status: 409

POST /dashboard/projects/:projectId/credentials/:store/forwarding refused a URL: the store (details.store) already forwards to the maximum number of URLs (details.max). Nothing was added.

What to do: Remove a forward URL first.


PUBSUB_API_DISABLED

HTTP status: 400

POST /dashboard/projects/:projectId/credentials/google/pubsub/connect (also Recreate) or …/pubsub/rotate-token: Google answered 403 SERVICE_DISABLED — the Cloud Pub/Sub API is not enabled in the Google Cloud project of the stored service account (details.gcpProject, details.clientEmail). details.enableUrl is Google's own activation link when Google sent one. Nothing was created.

What to do: Open details.enableUrl (or Google Cloud → APIs & Services → Cloud Pub/Sub API → Enable), wait a minute, then Recreate.


PUBSUB_PERMISSION_MISSING

HTTP status: 400

POST …/credentials/google/pubsub/connect or …/pubsub/rotate-token: Google answered 403 to a Pub/Sub admin call (create the topic, read or set its IAM policy, create or update the push subscription). The stored service account (details.clientEmail) lacks a Pub/Sub permission in details.gcpProject. Whatever was created before the refusal stays; Recreate reuses it.

What to do: Grant the service account the Pub/Sub Admin role (roles/pubsub.admin) in Google Cloud → IAM, then Recreate.


PUBSUB_ORG_POLICY

HTTP status: 400

POST …/credentials/google/pubsub/connect: granting Google Play's publisher (google-play-developer-notifications@system.gserviceaccount.com) on the topic was refused with FAILED_PRECONDITION — an organization policy such as Domain Restricted Sharing (constraints/iam.allowedPolicyMemberDomains) does not allow members outside the organization in details.gcpProject. The topic exists; Play cannot publish to it.

What to do: An organization admin must allow system.gserviceaccount.com (or Google Play's service account) for this project, then Recreate. Or use a Google Cloud project outside that policy.


PUBSUB_TOPIC_NOT_FOUND

HTTP status: 400

POST …/credentials/google/pubsub/connect with existingTopic: Google has no such topic in details.gcpProject (a bare id is looked up in the service account's own project). Nothing was created.

What to do: Copy the topic name from Play Console → Monetize → Monetization setup (or Google Cloud → Pub/Sub → Topics) and try again, or leave the field empty to let Rovenue create its own topic.


PUBSUB_ENDPOINT_NOT_HTTPS

HTTP status: 400

POST …/credentials/google/pubsub/connect or …/pubsub/rotate-token, before any call to Google: this server's Google notification URL (details.url, built from PUBLIC_BASE_URL) is not a public https URL. details.reason says why: not_https (plain http), private_address (a loopback/private IP, IPv6-mapped forms included), single_label_host, local_name (localhost, .local, .internal) or invalid_url; details.endpointScheme is its scheme. Pub/Sub pushes only to public https endpoints.

What to do: Set PUBLIC_BASE_URL to the API's public https address and restart the API, or set the subscription up by hand (Set it up by hand). Not retryable until then.


PUBSUB_CONNECT_IN_PROGRESS

HTTP status: 409

POST …/credentials/google/pubsub/connect or …/pubsub/rotate-token: another connect, Recreate or token rotation for this project holds the project's credential lock right now. This call changed nothing; the running one decides the push token Google and Rovenue share. Also answered by PUT /dashboard/projects/:projectId/credentials/:store, by DELETE …/credentials/:store (details.action: "disconnect") and, as a non-2xx Pub/Sub redelivers, by a cloud first-use push, when that lock is held longer than they wait.

What to do: Wait a few seconds and read the store status again; retry only if the running call failed.


WEBHOOK_IN_PROGRESS

HTTP status: 409

A store webhook (Apple, Google Pub/Sub, Stripe Connect) delivered an event that another request is processing right now. Nothing was done; the answer carries Retry-After (seconds). Stores retry any non-2xx, so the event is delivered once the running request settles — or, if it crashed, once its processing lease lapses.

What to do: None needed: the store retries automatically.


STORE_TEST_NOTIFICATION_TOKEN_UNKNOWN

HTTP status: 400

GET /dashboard/projects/:projectId/credentials/apple/test-notification/:token was given a token this API did not issue, or one Apple no longer knows (Apple answered 404 — test notification tokens expire). Says nothing about the stored key; no check row is written.

What to do: Send a new test notification (POST …/credentials/apple/test-notification) and poll the token it returns.


STORE_ID_TAKEN

HTTP status: 409

POST/PATCH /dashboard/projects/:projectId/products (and Rovi's product create) refuse a store id another product of the project already maps; a purchase of that SKU would resolve to either product. The message names the holder; error.details carries the store (store: apple | google | stripe), the id (storeId) and the product holding it (product: { id, identifier }).

What to do: Edit the named product (or remove its store id) instead, or use a different store product.


STRIPE_PRICE_NOT_FOUND

HTTP status: 400

The stripe store id is not a price on the project's connected Stripe account (checked only when the id changes and Stripe is connected).

What to do: Copy the price id (price_…) from the connected account's product page, or import it through Import from store → Web.


STORE_CANCEL_UNSUPPORTED

HTTP status: 409

POST /dashboard/projects/:projectId/subscriptions/:purchaseId/schedule refuses an App Store purchase: Apple offers no server API to cancel a customer's subscription, so there is nothing to schedule — the customer's own cancel arrives as a DID_CHANGE_RENEWAL_STATUS notification.

What to do: Do not schedule the cancel; tell the customer to cancel in their App Store subscriptions. The dashboard hides the action for App Store purchases.


REVOKE_IMMEDIATELY_UNSUPPORTED

HTTP status: 400

POST …/subscriptions/:purchaseId/schedule refuses revokeImmediately on a non-MANUAL purchase: only an operator comp (MANUAL) can be cut short locally; a store subscription runs to its term end.

What to do: Schedule without revokeImmediately, or revoke access through the store itself.


apple_offer_signing_unavailable

HTTP status: 400

POST /v1/apple-offer-signature couldn't load complete Apple credentials for the project (missing private key, keyId, or bundleId) — promotional-offer signing needs the same in-app-purchase signing key used for StoreKit offer signatures, and it isn't fully configured.

What to do: An operator must upload the project's Apple in-app-purchase signing key (and confirm keyId/bundleId are set) in the dashboard before this endpoint can sign offers. Not something the SDK caller can fix.


apple_offer_signing_failed

HTTP status: 400

Credentials were present, but signOfferPayload threw while actually producing the cryptographic signature for the promotional offer — most likely a malformed or corrupted stored private key, since the payload itself is built from validated request fields.

What to do: Not retryable by the SDK caller as-is; if this is new, an operator should re-upload the project's Apple in-app-purchase signing key in the dashboard, since the stored key material is likely invalid. If it was working and suddenly isn't, check for a recent credential rotation.


FONT_FORMAT_UNSUPPORTED

HTTP status: 400

The uploaded font file's bytes didn't match any allowed font format under detectFontFormat's magic-byte check. This is a shape check on the actual bytes, never the filename — a .ttf-named file carrying non-font bytes (or bytes for an unsupported format) triggers this regardless of its extension.

What to do: Upload an actual TTF/OTF/WOFF/WOFF2 font file (whichever formats detectFontFormat currently allows) — renaming a file's extension does not change how it's detected. Verify the file wasn't corrupted in transfer.


FONT_FILE_TOO_LARGE

HTTP status: 400

The uploaded font face exceeds FONT_FACE_MAX_BYTES. Returned by two gates that produce the identical code/message on purpose: hono/body-limit's transport-level rejection for a body large enough to exceed even the framing allowance, and the in-handler file.size check for the ordinary case where the file itself is just too big.

What to do: Compress or subset the font file (e.g. drop unused glyphs/hinting) to fit under the documented per-face size limit before uploading again.


FONT_QUOTA_EXCEEDED

HTTP status: 400

The project already has FONT_FACES_MAX_PER_PROJECT font faces and this upload would add a new one (not replace an existing (familyId, weight, style) combination, which is exempt from this check since it replaces a row rather than growing the count).

What to do: Delete an unused font face first, or upload this as a replacement weight/style for an existing family instead of a new face. This is a hard per-project cap, not a billing-tier limit that can be upgraded away.


FONT_FAMILY_NOT_FOUND

HTTP status: 404

A client-supplied familyId (on face upload, or on the family-level routes) doesn't resolve to a live font family owned by this project — missing entirely, belonging to a different project, or soft-deleted.

What to do: Re-fetch the project's current font families and use a live familyId from that list. A previously valid id that now 404s here was most likely deleted since you last fetched it.


ASSET_FORMAT_UNSUPPORTED

HTTP status: 400

The uploaded bytes don't match the asset kind the upload route claims (image/video/lottie), per detectAssetKind's magic-byte check — for example, non-video bytes posted to the video upload endpoint. Like the font check, this is a byte-shape check, never a filename or declared-Content-Type check. error.details.kind names the kind the bytes were expected to be.

What to do: Upload to the endpoint matching the file's actual kind (/image, /video, or /lottie), and confirm the file isn't corrupted or a placeholder — SVG is deliberately blocked here regardless of endpoint (see the asset-CDN libvips allowlist).


ASSET_FILE_TOO_LARGE

HTTP status: 413

The uploaded asset exceeds that asset kind's own size cap (ASSET_MAX_BYTES[kind]), enforced entirely at the transport level by a per-kind hono/body-limit registration — each of /image, /video, and /lottie has its own cap, so this always means the specific kind's own limit, not a shared one.

What to do: Compress the asset (re-encode the image/video, or strip unused data from the Lottie JSON) to fit the size limit for its kind, or use a smaller source file.


ASSET_QUOTA_EXCEEDED

HTTP status: 402

The project's total asset storage usage has reached its billing-tier limit (getStorageUsage against the tier's cap) — checked before any normalization or upload work is done, so this is returned cheaply without ever touching the file's bytes.

What to do: Delete unused assets to free up quota, or upgrade the project's billing tier for a higher storage cap — this is a paid-plan limit, not a per-request or per-file cap.


ASSET_STORAGE_UNAVAILABLE

HTTP status: 503

Either the asset storage backend (S3-compatible object store) isn't configured for this deployment at all (store.isStorageConfigured() false), or it was configured but the actual write to it failed — both return the same code so a caller doesn't need to distinguish 'not set up' from 'temporarily down'.

What to do: Retry shortly for the transient-outage case. If it never succeeds, an operator needs to check the deployment's object-storage configuration (endpoint, credentials, bucket) — this is not something an SDK/dashboard caller can fix by retrying indefinitely.


ANALYTICS_UNAVAILABLE

HTTP status: 503

An analytics read (charts, overview, transactions, cohorts, the queries playground and the other ClickHouse-backed dashboard routes) on a deployment with no ClickHouse — CLICKHOUSE_URL is blank, so ClickHouseUnavailableError was thrown and middleware/error.ts mapped it here. Postgres-backed reads on the same page still work.

What to do: Not transient — do not retry. An operator sets CLICKHOUSE_URL (plus its credentials) and runs the ClickHouse migrations to turn analytics on; until then a client shows an 'analytics not configured' state rather than an error.


ASSET_INVALID_NAME

HTTP status: 400

The name query parameter on an asset upload failed isValidAssetName — missing, empty, or containing characters the asset naming scheme doesn't allow.

What to do: Pass a non-empty name using only the allowed character set (see isValidAssetName) — this is checked before authentication-adjacent work runs, so it's the cheapest possible rejection and always means the request itself is malformed.


ASSET_PROCESSING_FAILED

HTTP status: 400

Image normalization (normalizeImage — resizing, EXIF/GPS stripping, format conversion via libvips/sharp) threw while processing an otherwise format-valid image. The bytes passed the magic-byte format check but libvips couldn't actually decode/process them — a truncated, corrupted, or unusually malformed file. The message is the image pipeline's own sentence.

What to do: Re-export the image from its source application and re-upload — this usually means the file is subtly corrupted or uses an edge-case encoding libvips can't handle, not that the format itself is wrong (that's ASSET_FORMAT_UNSUPPORTED).


IMPORT_FILE_TOO_LARGE

HTTP status: 413

The uploaded subscriber-import file (CSV) exceeds IMPORT_MAX_UPLOAD_BYTES, enforced by hono/body-limit at the transport level on POST /dashboard/projects/:projectId/imports before the body is ever fully buffered.

What to do: Split the import into smaller batches under the documented size limit, or remove unnecessary columns/rows from the CSV before re-uploading.


IMPORT_STORAGE_UNAVAILABLE

HTTP status: 503

The dedicated private import-file bucket (never the public asset bucket — see lib/import-store.ts) is either unconfigured for this deployment or the write to it failed. Both cases return the same code, mirroring the ASSET_STORAGE_UNAVAILABLE pattern.

What to do: Retry shortly for a possible transient outage. If it never succeeds, an operator needs to check the deployment's import-storage configuration — this bucket is configured separately from the public asset CDN bucket and both must be set up independently.


FEATURE_NOT_CONFIGURED

HTTP status: 503

This deployment lacks the configuration the request needs, so it was refused before anything was written. error.details.feature names what is missing: billing (a cloud deployment whose platform Stripe billing keys are missing, or refused — details.reason: "live_keys_refused" for live keys outside production; a self-hosted deployment has no billing routes at all and answers 404), apple_webhook_verifier (the Apple notification verifier could not be built), apple_receipt_verifier (an Apple receipt cannot be chain-verified: no Apple trust anchors are configured), upload_staging (MCP asset/font uploads without staging storage), unsubscribe_signing (the unsubscribe link without UNSUB_SIGNING_KEY), credential_encryption (saving or reading stored credentials without ENCRYPTION_KEY), dsar_erasure (POST /v1/dsar/erasure or the dashboard's Anonymize action without the ClickHouse eraser credentials), stripe_connect (POST /dashboard/projects/:projectId/stripe/connect for a Connect mode — details.mode — with no client id configured). The response carries no Retry-After.

What to do: Do not retry: the same request is refused until an operator configures the named feature (for dsar_erasure, CLICKHOUSE_ERASER_PASSWORD and the ClickHouse eraser user; for stripe_connect, that mode's Stripe Connect client id and platform keys). A client shows a 'not available on this deployment' state rather than an error.


DATABASE_UNAVAILABLE

HTTP status: 503

Postgres could not be reached: a refused or reset connection, a pool connect timeout, a terminated connection, or a connection-class SQLSTATE (08xxx, 57P01 admin shutdown, 57P03 cannot connect now, 53300 too many connections). Only connection-level failures map here — never a constraint, syntax or data error. The response carries Retry-After.

What to do: Retry after the Retry-After seconds with backoff. Nothing about the request was wrong; if it persists, an operator checks the database.


CACHE_UNAVAILABLE

HTTP status: 503

Redis could not be reached: the connection is closed, refused, or out of retries for the command. The response carries Retry-After.

What to do: Retry after the Retry-After seconds with backoff. If it persists, an operator checks Redis.


QUEUE_UNAVAILABLE

HTTP status: 503

The request needed to enqueue background work (an invitation email, an import run or dry-run, a DSAR export or erasure, an integration redelivery or backfill, a store webhook) and the job queue (Redis) rejected the job or did not accept it within the enqueue timeout. error.details.queue names the queue. The response carries Retry-After.

What to do: Retry after the Retry-After seconds. Every enqueue is idempotent by row id or claim, so a retry never duplicates the work even if the refused job landed late.


ANALYTICS_UNREACHABLE

HTTP status: 503

ClickHouse is configured but did not answer an analytics read: the network failed, the request timed out, or ClickHouse refused the credentials (AUTHENTICATION_FAILED, IP_ADDRESS_NOT_ALLOWED). Distinct from ANALYTICS_UNAVAILABLE, which means ClickHouse is not configured at all. The response carries Retry-After.

What to do: Retry after the Retry-After seconds; show a temporary 'analytics unreachable' state, never the 'analytics not configured' one. If it persists, an operator checks ClickHouse's reachability and credentials.


purchase_not_paid

HTTP status: 400

A Google receipt was submitted for verification while the underlying purchase is still PENDING (subscription subscriptionState or one-time purchaseState) — the user has started but not finished paying (e.g. a pending cash/carrier-billing payment method). This is machine-readable specifically because the purchase may still complete.

What to do: Retry receipt verification after the payment actually completes on-device — don't treat this as a hard failure or show a generic purchase-failed error. Poll or wait for the client's own purchase-update callback before re-verifying.


SUBSCRIBER_UNRESOLVABLE

HTTP status: 409

This device's subscriber record cannot be resolved.

What to do: The subscriber's merge history is inconsistent. Do not retry; contact the project owner, who can see the logged chain head.


PAYLOAD_TOO_LARGE

HTTP status: 413

The API-wide global body-size ceiling (app.ts's root bodyLimit) was exceeded on a route that doesn't own a larger, more specific limit of its own (asset/font/import uploads are carved out with their own bigger caps via ROUTE_OWNED_BODY_LIMIT_PATH). This code exists specifically because Hono's default body-limit rejection surfaced as HTTP_ERROR with an EMPTY message before it was added — a real incident, not a hypothetical.

What to do: This means the request body is far larger than any ordinary JSON payload this API expects on a non-upload route — check you're posting to the right endpoint, and use one of the dedicated upload routes (assets/fonts/imports) for large file bodies instead.


STRIPE_NOT_CONNECTED

HTTP status: 503

The project has no active Stripe Connect account to charge or open a portal session on. Produced at three sites, each keeping its own pre-existing HTTP status: POST /v1/billing-portal (503, lib/stripe-platform.ts's StripeNotConnectedError caught and re-thrown with cause), the dashboard funnel-publish checkout gate (dashboard/funnels.ts, 400), and the public funnel-payment payment-intent route (public/funnel-payment.ts, 409). As of Task 5 (2026-09-06) all three set cause: ERROR_CODE.STRIPE_NOT_CONNECTED, so error.code is this value from every one of them — read error.code; the message is a plain sentence.

What to do: An operator must connect Stripe for this project (dashboard's Stripe Connect flow) — and, for the funnel routes specifically, complete Stripe's own verification so charges_enabled is true — before the billing portal, a funnel with a checkout can be published, or a funnel-payment checkout can proceed.


STRIPE_CUSTOMER_NOT_FOUND

HTTP status: 404

The authenticated subscriber (resolved from X-Rovenue-App-User-Id) has no Stripe customer record on file. This is the normal, expected case for a subscriber whose entitlements come entirely from Apple or Google — not an error state in the usual sense.

What to do: Don't show this as a generic error to an Apple/Google-only subscriber — hide or disable the 'manage billing' entry point for them instead of surfacing this code as a failure, since it's expected rather than exceptional.


STORE_LOOKUP_UNAVAILABLE

HTTP status: 503

GET /dashboard/store-lookup/apple when the iTunes Search API did not answer (timeout, non-2xx, unreachable — e.g. a self-hosted install without egress).

What to do: Enter the app manually; search works again once Apple answers.


RETURN_URL_NOT_ALLOWED

HTTP status: 400

The returnUrl posted to /v1/billing-portal is neither on one of the authenticating API key's allowed origins (an exact scheme + host + port match) nor on one of the project's verified custom domains served over https — this request's URL is never echoed back to Stripe unchecked, precisely because the returned session URL grants access to payment data.

What to do: Either add the web app's origin (e.g. https://app.example.com, or http://localhost:5173 while developing) to the API key's allowed origins, or verify the domain in the dashboard's project settings and pass an https URL on that exact verified domain. This is a security check, not something to work around by retrying with the same value.


WRONG_PAGE

HTTP status: 409

runMachineStep (apps/api/src/routes/public/funnels.ts, the shared body behind /current, /back, /submit and /tap) rejects the request's from_page_id before doing any work — the pre-transaction cheap check at line 567 — because the session's stored currentPageId no longer matches it. This means the browser tried to act from a page the session isn't actually on anymore: another tab already moved it, or a duplicate/replayed request is targeting a page the visitor already left. The authoritative compare-and-set inside the transaction (line 648) also checks from_page_id, but a race lost there collapses into STEP_STALE or SESSION_CLOSED instead — this code is only ever raised by the early, pre-transaction check.

What to do: Not a client bug to fix by retrying the same call. The dashboard runner (funnel-runner.tsx's handleStepFailure/resync) treats this as recoverable: it reads GET /funnel-sessions/:id/state — the one read with no compare-and-set of its own — and replays /current with the session's real current_page_id/machine_step, dropping the tap that triggered the 409 rather than retrying it. A second consecutive 409 (including one from that resync's own /current call) is terminal by construction, not resynced again.


STEP_STALE

HTTP status: 409

runMachineStep rejects the request's step counter, either at the pre-transaction cheap check (line 573, session.machineStep !== envelope.step) or from losing the authoritative compare-and-set inside the transaction (line 648) when the session's state is still RUNNING — the CAS failure classifies as STEP_STALE whenever it is not also closed, so a same-step race that actually moved the page can still surface as this code rather than WRONG_PAGE. Either way it means the visitor's cursor is behind the session's real step count: a duplicated request (the same step submitted twice) or another tab that already advanced it.

What to do: Same recovery as WRONG_PAGE: the dashboard runner's resync() reads GET /funnel-sessions/:id/state for the session's real current_page_id/machine_step and replays /current with those values, dropping the request that got refused. Do not blindly retry the original call with the same step — it will refuse again. A second consecutive 409 (including from the resync's own call) is treated as terminal, not looped.


SESSION_CLOSED

HTTP status: 409

runMachineStep refuses because the session itself has stopped taking steps, not because the visitor's cursor is behind. Two throw sites: the entry check (line 536, before any page/step is even looked at) when session.state !== RUNNING already; and the compare-and-set failure inside the transaction (line 648) when the CAS lost AND the session's freshly re-read state is no longer RUNNING. In practice this means the session was already paid, completed, or closed by funnel-abandoner.ts while this tab sat idle or mid-flight.

What to do: Not recoverable by resyncing — there is no later page to catch up to. The dashboard runner (funnel-runner.tsx) does not attempt resync() for this code; handleStepFailure routes it straight to a { kind: 'closed' } status, rendering its own 'This session has ended' screen instead of the generic error screen or a retry loop.


OFFERING_IN_USE

HTTP status: 409

DELETE /dashboard/projects/:projectId/offerings/:id refuses because a LIVE funnel — published, with a current version, web or native — sells this offering: its current version's package lists name it, read through the same funnelOfferingIdentifier the funnel serve, the placement resolver and the payment route use. Deleting it would leave that funnel's package lists empty and its checkout failing. The message names the funnels; error.details carries the offering's identifier (offering) and the live funnels (funnels: { id, name }[]).

What to do: Unpublish or archive the named funnels (or publish a version that sells a different offering) first, then delete the offering. Draft and archived funnels never block a delete: their next publish refuses the missing offering as OFFERING_NOT_FOUND.


PRODUCT_IN_USE

HTTP status: 409

DELETE /dashboard/projects/:projectId/products/:id refuses a product that is a package in an offering; the package would dangle and any live funnel selling that offering would silently lose it. The message names the offerings and the live funnels; error.details.offerings ({ id, identifier }[]) and error.details.funnels ({ id, name }[], empty when no live funnel sells them) carry them. A product with purchase history is PRODUCT_HAS_HISTORY instead.

What to do: Remove the product from the named offerings first (Offerings → the offering → remove), then delete it.


PRODUCT_HAS_HISTORY

HTTP status: 409

DELETE /dashboard/projects/:projectId/products/:id refuses a product that purchases or revenue events reference (RESTRICT foreign keys): history can never be un-referenced. error.details.purchases lists up to five of the referencing purchases, newest first ({ id, store, originalTransactionId }); error.details.truncated is true when more exist. A product referenced only by revenue events has purchases: [].

What to do: Archive the product instead (PATCH { isActive: false }): it stops being offered and keeps its history.


ACCESS_IN_USE

HTTP status: 409

DELETE /dashboard/projects/:projectId/access/:id refuses an access level that live subscriber_access rows still hold (the subscriber_access_accessId_access_id_fk RESTRICT reference). The delete unlinks the access from products itself, so only subscribers block; error.details.subscriberCount names how many hold it.

What to do: Wait for the grants to lapse (or revoke them), then delete the access level.


CUSTOM_HOST_PATH_NOT_FOUND

HTTP status: 404

GET /public/host/lookup (apps/api/src/routes/public/funnels.ts) returns this when the request's Host header IS a verified, serving project custom domain, but nothing is published at the requested path — no root funnel, a reserved slug, or no published funnel matching the first path segment. Distinct from the plain NOT_FOUND an unrecognized host gets: a customer's own domain must never show the Rovenue dashboard, so the SPA has to answer these two cases differently.

What to do: Treat this as 'render the funnel-side 404 page', not the dashboard's generic not-found — the host is legitimately theirs, only the path is wrong. If the caller expected a specific funnel slug to resolve, verify it is published with a current version and isn't a reserved slug.


FUNNEL_VALIDATION

HTTP status: 400

POST /dashboard/projects/:projectId/funnels/:funnelId/publish refuses because the draft does not publish: it fails the page schema, the Rust validator reports errors, a gate (checkout/legacy rules) fails, or the offering it sells does not exist in the project. error.details.issues lists every problem in the validator's vocabulary. Also returned with 422 by GET /dashboard/projects/:projectId/funnels/:funnelId/preview-document?version=draft when the draft cannot be built into a document at all (its pages fail the page schema); error.details.issues then carries { path, code, message } entries. A draft that builds but has validator errors is NOT refused there: it is served with those errors in data.issues.

What to do: Fix each issue in error.details.issues in the builder (its publish drawer lists them) and publish again. Warnings never appear here; only errors block.


FUNNEL_SETTINGS_INVALID

HTTP status: 400

PATCH /dashboard/projects/:projectId/funnels/:funnelId refuses draft_settings_json that fails the persisted funnel-settings schema. error.details.issues carries the schema's issues.

What to do: Correct the settings the issues point at (a malformed scheme, a bad app-value name) and save again.


FUNNEL_LOCALE_MISMATCH

HTTP status: 400

PATCH /dashboard/projects/:projectId/funnels/:funnelId refuses a write after which the funnel's default_locale is not one of its locales (sent one without the other).

What to do: Send a default_locale that is in locales, or add it to locales in the same request.


FIRST_API_KEY_EXISTS

HTTP status: 409

POST /dashboard/projects/:id/api-keys/first (the onboarding keys step) found the project already holding an active API key, so it minted nothing: the first key is created once, even when two tabs ask at the same moment. error.details.publicKey is the existing key's publishable value.

What to do: Use the key the project already has, or create more keys on the SDK & API page (POST /dashboard/projects/:id/api-keys). Retrying unchanged fails the same way.


FUNNEL_SLUG_TAKEN

HTTP status: 409

A web funnel's slug write (PATCH or convert to web) collides with another funnel's slug in the same project. error.details.slug names the slug.

What to do: Choose a different slug; slugs are unique per project.


FUNNEL_TARGET_CHANGED

HTTP status: 409

The funnel's target changed (a conversion in another tab) since the caller judged or edited it — a PATCH with expected_target, or a publish that raced a conversion. error.details.target is the funnel's current target.

What to do: Reload the funnel; the builder stops autosaving and tells the author the funnel was converted.


FUNNEL_ALREADY_ON_TARGET

HTTP status: 400

POST /dashboard/projects/:projectId/funnels/:funnelId/convert asked for the target the funnel already has. error.details.target names it.

What to do: Nothing to do; only convert to the other target.


FUNNEL_CONVERSION_BLOCKED

HTTP status: 400

POST …/funnels/:funnelId/convert refuses because the draft carries something the new target cannot (a checkout on native, a store purchase on web, …). error.details.issues lists the blockers.

What to do: Remove or replace each blocker in the builder, then convert again. GET …/convert?target= previews the same list without converting.


FUNNEL_CONVERSION_UNPUBLISHES

HTTP status: 409

POST …/funnels/:funnelId/convert refuses because the funnel is live and converting would take it off the air. error.details.stopsServing names what stops serving.

What to do: Resend with unpublish: true to acknowledge the funnel stops serving, then publish it again on its new target.


FUNNEL_IN_USE

HTTP status: 409

A funnel archive/delete refuses because placements or a running experiment still serve it. error.details lists placements ({id, identifier, name}) and experiments ({id, name, status}).

What to do: Point the listed placements at another target and stop the listed experiments, then archive again.


FUNNEL_ARCHIVED

HTTP status: 409

POST /dashboard/projects/:projectId/funnels/:funnelId/publish refuses an archived funnel: publishing would put it back on the air without the restore that re-checks it. POST /dashboard/experiments/:id/stop refuses the same way when the winning variant's funnel is archived, since the stop would point placements at it.

What to do: Restore the funnel (POST .../funnels/:funnelId/restore), then publish it (or stop the experiment with it as the winner).


FUNNEL_DRAFT_SUPERSEDED

HTTP status: 409

PATCH /dashboard/projects/:projectId/funnels/:funnelId refuses a save carrying a writer_id whose save_seq is not above the one the funnel last stored from that writer — an older save from the same editor tab (an aborted request, or a retry of one that already landed) arriving after a newer one. Saves from different writers (other tabs) are never refused.

What to do: Nothing to do: the newer save from the same editor is already stored. The builder drops the refused save silently.


FUNNEL_SLUG_RESERVED

HTTP status: 400

A web funnel create, PATCH or copilot intent names a slug no funnel may take (assets — custom domains serve the dashboard bundle's static files there).

What to do: Choose a different slug.


FUNNEL_NOT_NATIVE

HTTP status: 400

GET /dashboard/projects/:projectId/funnels/:funnelId/preview-document refuses a web-target funnel: the device preview renders a native funnel through the SDK's native views, and a web funnel is previewed at its own URL instead.

What to do: Preview a web funnel in a browser at its funnel URL, or convert it to a native funnel first.


NO_PUBLISHED_VERSION

HTTP status: 404

GET /dashboard/projects/:projectId/funnels/:funnelId/preview-document?version=published finds nothing on the air for the funnel: it was never published, or it was archived or restored to draft, so no placement would serve it.

What to do: Preview version=draft instead, or publish the funnel first.


INVALID_ROW_REF

HTTP status: 400

A placement create/update names, in one of its rows, an audience, funnel or experiment that does not belong to the project, or an experiment that is not a FUNNEL experiment. The message names the row reference; error.details carries what kind it is (kind: audience | funnel | experiment | experimentType) and the ids (ids).

What to do: Pick targets from this project only (the placement editor's pickers list exactly those) and save again.


audience_in_use

HTTP status: 409

DELETE /dashboard/audiences/:id refuses an audience that an experiment, a feature flag rule or a placement row still references; error.details.references lists each as { type, id, name }.

What to do: Point those experiments, flags and placements at another audience (or remove the rules), then delete the audience.


CUSTOM_DOMAIN_LIMIT_REACHED

HTTP status: 409

POST /dashboard/projects/:projectId/custom-domains refuses on a cloud deployment (HOST_MODE=cloud) because the project already has its one custom domain. Self-hosted deployments have no limit.

What to do: Detach the existing custom domain first; one domain serves every web funnel of the project.


HOSTNAME_TAKEN

HTTP status: 409

POST /dashboard/projects/:projectId/custom-domains refuses a hostname another custom domain (in any project) already holds. Hostnames are unique across the deployment.

What to do: Use a different hostname, or detach it from the project that holds it first.


ROOT_FUNNEL_INVALID

HTTP status: 400

PATCH /dashboard/projects/:projectId/custom-domains/:id refuses a rootFunnelId that is not a published web funnel of this project.

What to do: Choose a published web funnel of this project as the root, or clear it (null) so / is a 404.


PAYMENT_ALREADY_RECORDED

HTTP status: 409

The public funnel-payment routes refuse because this session's payment is already recorded (or the purchase is already complete). Returned by payment-intent.

What to do: Stop trying to pay: the runner moves on to claiming the purchase.


PAYMENT_TEMPORARILY_UNAVAILABLE

HTTP status: 503

The public funnel-payment routes could not take the per-session payment lock (Redis unavailable), or the project's Stripe connection is gone while finalizing a payment that may already have been charged.

What to do: Retry after a moment. A buyer who was charged can restore the purchase from the app using the email they paid with.


PAYMENT_IN_FLIGHT

HTTP status: 409

Another payment attempt for this funnel session holds the payment lock, or won the race to write it.

What to do: Retry shortly; the other attempt will either finish the payment or release the lock.


PAYMENT_NOT_SETTLED_YET

HTTP status: 409

claim on the public funnel-payment routes: Stripe has not reported the payment as settled yet. Transient, unlike a terminal 409.

What to do: Retry with backoff; the runner does this automatically.


PAYMENT_PROVIDER_REJECTED

HTTP status: 422

payment-intent on the public funnel-payment routes: Stripe answered and refused to create the payment — a declined card (the message is Stripe's own), or a request the project's connected Stripe account cannot serve (for example an account type that cannot take direct charges). Nothing was charged. details carries stripeRequestId, stripeErrorType and stripeErrorCode. An unreachable Stripe is STORE_UNAVAILABLE instead.

What to do: A declined card: try another card. An account refusal (stripeErrorType other than StripeCardError): the project owner looks the request id up in their Stripe dashboard (Developers → Logs) and fixes the account — until then a retry is refused the same way.


PAYMENT_UNVERIFIABLE

HTTP status: 409

confirm on the public funnel-payment routes: Stripe answered but will not show this session's payment — typically the project reconnected a different Stripe account, so the recorded subscription or PaymentIntent is unknown there, or the account revoked Rovenue's access. Says nothing about whether the buyer paid. details carries stripeRequestId, stripeErrorType and stripeErrorCode.

What to do: The buyer must not pay again: if they were charged, they restore the purchase from the app with the email they paid with (the Connect webhook also completes it once it arrives). The project owner checks the request id in their Stripe dashboard.


CONFIRM_EMAIL_MISMATCH

HTTP status: 400

DELETE /dashboard/me (self-service account deletion): the confirmEmail in the body is not the signed-in account's email (compared case-insensitively). Nothing was deleted.

What to do: Type the account's own email address exactly as shown on the profile page to confirm the deletion.


OWNS_PROJECTS

HTTP status: 409

DELETE /dashboard/me refuses because the caller is still an OWNER of one or more projects; deleting the account would leave them ownerless. The message names the projects; error.details.projects carries them ({ id, name }[]). Nothing was deleted.

What to do: Transfer ownership of each named project to another member (Members page), or delete the project, then delete the account again.


exposure_out_of_range

HTTP status: 400

POST /v1/experiments/:id/expose: the exposedAt is before the experiment's startedAt or more than five minutes in the future. An honest client cannot have been served the experiment before it started. No exposure was recorded.

What to do: Send the time the variant was actually shown, or omit exposedAt to have the server use the receive time. Check the device clock if this happens for recent exposures.


EXPERIMENT_NOT_RUNNING

HTTP status: 409

POST /v1/experiments/:id/expose on an experiment that is not RUNNING (draft, paused or completed). It has no live assignment surface, so the exposure is not recorded.

What to do: Nothing to retry: the same exposure is refused until the experiment runs again. The SDKs record it as sent and stop re-posting it.


promote_requires_flag_experiment

HTTP status: 400

POST /dashboard/experiments/:id/stop with promoteToFlag: true on a FUNNEL or OFFERING experiment. Only a FLAG experiment's winning value is a feature-flag value. The experiment was not stopped. error.details.type is the experiment's type.

What to do: Stop without promoteToFlag. A FUNNEL experiment's winner repoints its placements; an OFFERING experiment just completes with the winner recorded.


window_too_large

HTTP status: 400

GET /metrics/mrr-movement refuses a window longer than 1 096 days, or longer than 400 days at grain=day; error.details carries { days, maxDays }.

What to do: Ask for a shorter window or a coarser grain (week/month/total).


USAGE_LIMIT_EXCEEDED

HTTP status: 403

middleware/usage-lock.ts answers the dashboard's project feature routes with this once the project exceeded its plan's hard caps for two consecutive billing periods (projects.usage_locked_at). Project administration stays reachable (the project itself, billing, members, invitations, API keys, webhook secret), and SDK /v1 and store webhooks are untouched: data collection never stops. Only where billing is enabled.

What to do: Upgrade the project's plan from its billing page. The feature routes answer this until the lock is lifted.


BILLING_CARD_IN_USE

HTTP status: 409

DELETE /dashboard/projects/:projectId/billing/payment-methods/:pmId refuses the project's last card while the project is on a paid plan: metered billing collects off-session from it.

What to do: Cancel metered billing first; remove the card once the project is back on Free.


BILLING_CARD_OPEN_INVOICE

HTTP status: 409

DELETE /dashboard/projects/:projectId/billing/payment-methods/:pmId refuses the project's last card while an invoice of the project is still open (e.g. the final period's, billed after the project went back to Free): it is collected from the card on file.

What to do: Pay the open invoice from Invoices first, then remove the card.


WEBHOOK_EVENT_EXPIRED

HTTP status: 410

POST /dashboard/projects/:projectId/integrations/:id/deliveries/:deliveryId/redeliver when the delivery's originating outbox event is no longer stored (pruned by retention, or a delivery recorded before deliveries kept their source row). The delivery row itself is kept.

What to do: Nothing to retry: the event cannot be rebuilt. Replay the underlying change at its source if the receiver still needs it.


WEBHOOK_EVENT_UNMAPPABLE

HTTP status: 422

POST .../deliveries/:deliveryId/redeliver when the originating outbox event still exists but no longer maps onto a deliverable webhook event (its event type was retired).

What to do: Nothing to retry: the same delivery is refused every time.


STALE_BATCH

HTTP status: 422

POST /v1/sdk/sessions when every event of the batch lies outside the 48-hour freshness window. Nothing was recorded. A batch with only some stale events is accepted with the stale ones dropped.

What to do: Drop the batch; resending it is refused the same way. The SDKs do this on their own.


TELEMETRY_UNAVAILABLE

HTTP status: 503

POST /v1/sdk/sessions when the session pipeline (Kafka) is not configured or the produce failed. Nothing was recorded.

What to do: Keep the batch and retry with backoff. The SDKs buffer and retry on their own.


IDENTIFIER_TAKEN

HTTP status: 409

A dashboard create or rename collides with an identifier another row of the same kind already holds in the project: an access level, offering, product, placement or leaderboard identifier, a cohort name or a virtual-currency code. error.details.resource names the kind (access | offering | product | placement | leaderboard | cohort | virtualCurrency) and error.details.identifier the taken value.

What to do: Choose another identifier, or edit the existing row instead.


UNKNOWN_REFERENCES

HTTP status: 400

A dashboard write names rows that do not exist in the project (or cannot be used there): product ids in an offering's packages, access ids or virtual currencies on a product (404 for a currency), a variant id on an experiment update, funnels that are not native and live in this project as experiment variants, or a startAfterExperimentId from another project. error.details.resource names the kind (product | access | virtualCurrency | variant | funnel | experiment) and error.details.ids the refused ids.

What to do: Pick the rows from this project (the dashboard's pickers list exactly those) and save again.


IMPORT_JOB_STATE

HTTP status: 409

An import-job action the job's current state does not allow: editing the mapping or starting a dry run while the job runs, committing anything but a finished dry run, resuming a job that is not VERIFICATION_INCOMPLETE or VERIFYING, cancelling a finished job. error.details.action names the action (editMapping | dryRun | commit | resume | cancel) and error.details.state the job's ImportJobStatus.

What to do: Reload the job: act on it once it reaches a state that allows the action, or start a new import.


IMPORT_MAPPING_INCOMPLETE

HTTP status: 400

PATCH …/imports/:id/mapping or a dry run on a mapping that leaves required canonical fields unmapped. error.details.missingFields lists them.

What to do: Map a column to each listed field, then save the mapping again.


EXPERIMENT_STATE

HTTP status: 400

An experiment action its status does not allow: editing a PAUSED or COMPLETED experiment, deleting or starting anything but a DRAFT, pausing anything but a RUNNING experiment, resuming anything but a PAUSED one, stopping one that is neither. error.details.action names the action (edit | start | pause | resume | stop | delete) and error.details.status the experiment's ExperimentStatus.

What to do: Reload the experiment; its status changed or the action does not apply to it.


WEBHOOK_STATE

HTTP status: 400

POST /dashboard/webhooks/:id/retry or /dismiss on a delivery that is not DEAD. error.details.action names the action (retry | dismiss) and error.details.status the delivery's OutgoingWebhookStatus.

What to do: Only a dead delivery can be retried or dismissed; reload the list.


COPILOT_INTENT_SETTLED

HTTP status: 409

POST …/copilot/intents/:id/reject or /execute on an intent that is no longer pending. error.details.status is its status (rejected | expired | executed | failed).

What to do: Nothing to do: the intent was already settled. Ask Rovi again for a fresh one.


ROLE_REQUIRED

HTTP status: 403

A dashboard project route the caller's role ranks below (assertProjectAccess). error.details.role is the caller's MemberRole, error.details.requiredRole the lowest role the route accepts.

What to do: Ask a project owner for a role at or above the required one.


CAPABILITY_REQUIRED

HTTP status: 403

A dashboard action whose capability the caller's role lacks (assertProjectCapability, e.g. products:write, subscribers:import). error.details.role is the caller's MemberRole, error.details.capability the capability the action needs.

What to do: Ask a project owner for a role that includes the capability.


BILLING_NOTHING_TO_CANCEL

HTTP status: 409

POST …/billing/cancel (or its undo) when the project has no paid metered-billing subscription to schedule a cancellation on.

What to do: Nothing to cancel: the project is already on Free.


BILLING_ALREADY_ACTIVE

HTTP status: 409

POST …/billing/upgrade when the project already has active metered billing.

What to do: Nothing to do: the project is already upgraded.


BILLING_UPGRADE_REQUIRED

HTTP status: 409

POST …/billing/payment-methods when the project has no billing customer yet: cards are added during the upgrade.

What to do: Upgrade the project first; the upgrade collects the card.


STRIPE_ALREADY_CONNECTED

HTTP status: 409

POST /dashboard/projects/:projectId/stripe/connect when the project already has an active Stripe connection (connected from another tab meanwhile).

What to do: Reload the Stores page; disconnect the current account first to connect another.


ALREADY_MEMBER

HTTP status: 409

POST …/invitations for an email that already belongs to a project member. error.details.email is that email.

What to do: Nothing to send: change the member's role on the Members page instead.


ALREADY_OWNER

HTTP status: 409

An ownership transfer to a member who is already an OWNER.

What to do: Nothing to do: pick a member who is not an owner yet.


INVITATION_NOT_SENDABLE

HTTP status: 409

POST …/invitations/:id/resend on an invitation that cannot be sent again. error.details.reason is closed (accepted, revoked or expired) or suppressed (the address bounced or complained, so the mailer refuses it).

What to do: Closed: send a new invitation. Suppressed: invite another address, or have the person fix their mailbox and ask support to lift the suppression.


INVITATION_ALREADY_ACCEPTED

HTTP status: 409

POST /public/invitations/:token/accept on an invitation that was already accepted.

What to do: Nothing to do: open the project from the dashboard.


INVITATION_EMAIL_MISMATCH

HTTP status: 403

POST /public/invitations/:token/accept by a signed-in user whose email is not the one the invitation was sent to. error.details.email is the invited address.

What to do: Sign in with the invited email, or ask for an invitation to your own.


WEBHOOK_ENDPOINT_LIMIT

HTTP status: 409

Creating a custom-webhook integration when the project already has the maximum number of webhook endpoints. error.details.limit is the maximum.

What to do: Remove an endpoint you no longer use, then add the new one.


WEBHOOK_URL_REJECTED

HTTP status: 400

A custom-webhook URL the delivery guard refuses (create, or a PATCH of url). error.details.reason says why: malformed, scheme (not http/https), insecure (http in production), userinfo (credentials in the URL), host_not_allowed (localhost or an internal name), not_routable (an IP literal that is not public), unresolvable (DNS returned nothing), private_address (DNS resolves to a private address), or credentials_shape (credentials other than one url).

What to do: Use a public https URL your receiver serves, without credentials in it.


TEST_DELIVERY_NOT_REDELIVERABLE

HTTP status: 409

POST …/integrations/:id/deliveries/:deliveryId/redeliver on a test delivery: a test event has no source row to rebuild.

What to do: Send a new test event instead.


INTEGRATION_ALREADY_CONNECTED

HTTP status: 409

Creating a second connection for a provider that allows one per project (the partial unique index of migration 0104).

What to do: Edit the existing connection instead.


INTEGRATION_CREDENTIALS_REJECTED

HTTP status: 400

Creating or updating an integration whose credentials the provider check refused (a probe call the provider answered with an error, or a value the provider's own format rules reject). error.details.reason is the provider check's own words. A credentials object that fails the provider's schema is a VALIDATION_ERROR with details.fields under credentials. instead.

What to do: Correct the credentials in the provider's console (a fresh key, the right workspace) and save again.


SCHEDULED_ACTION_CONFLICT

HTTP status: 409

Scheduling or cancelling a subscription action from the dashboard against a state that does not allow it. error.details.reason: terminal (the purchase already ended), pending (an action is already scheduled for it), not_pending (the scheduled action already fired or was cancelled).

What to do: Reload the subscription; cancel the pending action first to schedule another.


HOSTNAME_INVALID

HTTP status: 400

POST …/custom-domains with a hostname that is not a valid DNS name (reason: hostname_invalid) or one Rovenue keeps for itself (reason: hostname_reserved). error.details.hostname is the normalised hostname.

What to do: Use a subdomain you own, such as pay.example.com.


ORIGIN_INVALID

HTTP status: 400

PATCH /dashboard/projects/:projectId with a browser origin the CORS allow-list cannot use (a path, a wildcard, a bad scheme). error.details.origin is the first refused value.

What to do: Give scheme, host and optional port only, such as https://app.example.com.


QUERY_REJECTED

HTTP status: 400

The SQL editor's query was refused: by the query validator (not a single read-only SELECT over the allowed tables) or by ClickHouse itself (a parse or type error). error.details.reason is the validator's or ClickHouse's own first line, so the editor can show it next to the query.

What to do: Fix the query where the reason points and run it again.


CURRENCY_GRANT_INVALID

HTTP status: 400

A product's virtual-currency grants cannot work as written. error.details.reason: renewal_needs_subscription (a RENEWAL or BOTH grant on a product that never renews) or granted_twice (one currency granted twice on one term; error.details.currency is its code).

What to do: Renewal grants: use On purchase, or make the product a subscription. Twice: keep one row per trigger, or a single Both row.


REFUND_NOT_POSSIBLE

HTTP status: 422

POST …/subscriptions/:purchaseId/refund or …/transactions/:id/refund cannot refund this purchase. error.details.reason: apple_unsupported (Apple offers no refund API; 422), missing_store_ref (the purchase carries no store transaction to refund against; 422) or already_refunded (it is already refunded or revoked; 409). Nothing was sent to the store.

What to do: Apple: the customer requests the refund from Apple. Missing reference: refund it in the store's console. Already refunded: nothing to do.


HTTP status: 409

PATCH /dashboard/projects/:projectId clearing termsUrl or privacyUrl (sending null) while a LIVE funnel — published, web or native — references it as {{project.terms_url}} / {{project.privacy_url}} in its current version. Funnels receive the links beside the document at serve time, so clearing one would leave those funnels with a broken legal link. Replacing a URL with another https URL is always allowed. error.details.field is the refused body field (termsUrl or privacyUrl); error.details.funnels lists the live funnels ({ id, name }[]).

What to do: Set a different https URL instead of clearing it, or publish versions of the named funnels that no longer reference the link (or unpublish them), then clear it.


ADMIN_EMAIL_DOMAIN

HTTP status: 403

A /admin/* request whose session user is not on a platform-admin email domain (PLATFORM_ADMIN_EMAIL_DOMAINS: exact, case-insensitive match after the last @ — look-alikes and subdomains do not match) or whose address is unverified. Re-checked on every request: an admin whose email later changes off-domain loses access immediately.

What to do: Sign in with a verified @rovenue.io or @broverse.ai address that holds a platform-admin grant.


ADMIN_2FA_REQUIRED

HTTP status: 403

A /admin/* request whose session user has no 2FA enabled. Platform admins must pass TOTP (the second-factor gate challenges OAuth and magic-link sign-ins, so every admin session has passed it).

What to do: Enable 2FA on the account and sign in again.


ADMIN_SESSION_EXPIRED

HTTP status: 401

A /admin/* request on a session older than ADMIN_SESSION_MAX_AGE_SECONDS (12 h) since its creation. Admin sessions are short-lived even when the underlying Better Auth session is still valid.

What to do: Sign in again on the admin host.


PROJECT_SUSPENDED

HTTP status: 403

A /dashboard/projects/:projectId/* request for a platform-suspended project (Terms §15). Suspension is dashboard-only: SDK, server-to-server, webhook and public-funnel traffic keeps serving, and billing continues. Only a platform admin can lift it.

What to do: Contact support@rovenue.io. There is no self-service unsuspend.


SUPPORT_ACCESS_READ_ONLY

HTTP status: 403

A mutating request through a read-only support grant. Break-glass access reads at CUSTOMER_SUPPORT level and writes nothing: every non-GET/HEAD/OPTIONS dashboard route, and every non-read capability, refuses with this code.

What to do: Read the data; make the change through a real membership.

On this page

Quick referenceCodesHTTP_ERRORVALIDATION_ERRORINTERNAL_ERRORUNAUTHORIZEDFORBIDDENNOT_FOUNDCONFLICTNOT_IMPLEMENTEDRATE_LIMITEDROVI_NOT_CONFIGUREDROVI_QUOTA_EXCEEDEDBEARER_REQUIREDINVALID_API_KEYINVALID_API_KEY_FORMATAPI_KEY_KIND_MISMATCHORIGIN_NOT_ALLOWEDSTORE_NOT_CONFIGUREDRECEIPT_INVALIDRECEIPT_PRODUCT_UNKNOWNRECEIPT_IN_USESTORE_API_ERRORSTORE_UNAVAILABLESTORE_CREDENTIALS_INVALIDSTORE_KEY_MISSINGSTORE_KEY_WRONG_TYPESTORE_NOTIFICATION_URL_IN_USESTORE_KEY_INSUFFICIENT_ROLESTORE_NOTIFICATION_URL_NOT_PUBLICSTORE_NOTIFICATION_URL_REJECTEDAPPLE_APP_ID_MISSINGAPPLE_APP_ID_MISMATCHNOTIFICATION_FORWARD_LIMITPUBSUB_API_DISABLEDPUBSUB_PERMISSION_MISSINGPUBSUB_ORG_POLICYPUBSUB_TOPIC_NOT_FOUNDPUBSUB_ENDPOINT_NOT_HTTPSPUBSUB_CONNECT_IN_PROGRESSWEBHOOK_IN_PROGRESSSTORE_TEST_NOTIFICATION_TOKEN_UNKNOWNSTORE_ID_TAKENSTRIPE_PRICE_NOT_FOUNDSTORE_CANCEL_UNSUPPORTEDREVOKE_IMMEDIATELY_UNSUPPORTEDapple_offer_signing_unavailableapple_offer_signing_failedFONT_FORMAT_UNSUPPORTEDFONT_FILE_TOO_LARGEFONT_QUOTA_EXCEEDEDFONT_FAMILY_NOT_FOUNDASSET_FORMAT_UNSUPPORTEDASSET_FILE_TOO_LARGEASSET_QUOTA_EXCEEDEDASSET_STORAGE_UNAVAILABLEANALYTICS_UNAVAILABLEASSET_INVALID_NAMEASSET_PROCESSING_FAILEDIMPORT_FILE_TOO_LARGEIMPORT_STORAGE_UNAVAILABLEFEATURE_NOT_CONFIGUREDDATABASE_UNAVAILABLECACHE_UNAVAILABLEQUEUE_UNAVAILABLEANALYTICS_UNREACHABLEpurchase_not_paidSUBSCRIBER_UNRESOLVABLEPAYLOAD_TOO_LARGESTRIPE_NOT_CONNECTEDSTRIPE_CUSTOMER_NOT_FOUNDSTORE_LOOKUP_UNAVAILABLERETURN_URL_NOT_ALLOWEDWRONG_PAGESTEP_STALESESSION_CLOSEDOFFERING_IN_USEPRODUCT_IN_USEPRODUCT_HAS_HISTORYACCESS_IN_USECUSTOM_HOST_PATH_NOT_FOUNDFUNNEL_VALIDATIONFUNNEL_SETTINGS_INVALIDFUNNEL_LOCALE_MISMATCHFIRST_API_KEY_EXISTSFUNNEL_SLUG_TAKENFUNNEL_TARGET_CHANGEDFUNNEL_ALREADY_ON_TARGETFUNNEL_CONVERSION_BLOCKEDFUNNEL_CONVERSION_UNPUBLISHESFUNNEL_IN_USEFUNNEL_ARCHIVEDFUNNEL_DRAFT_SUPERSEDEDFUNNEL_SLUG_RESERVEDFUNNEL_NOT_NATIVENO_PUBLISHED_VERSIONINVALID_ROW_REFaudience_in_useCUSTOM_DOMAIN_LIMIT_REACHEDHOSTNAME_TAKENROOT_FUNNEL_INVALIDPAYMENT_ALREADY_RECORDEDPAYMENT_TEMPORARILY_UNAVAILABLEPAYMENT_IN_FLIGHTPAYMENT_NOT_SETTLED_YETPAYMENT_PROVIDER_REJECTEDPAYMENT_UNVERIFIABLECONFIRM_EMAIL_MISMATCHOWNS_PROJECTSexposure_out_of_rangeEXPERIMENT_NOT_RUNNINGpromote_requires_flag_experimentwindow_too_largeUSAGE_LIMIT_EXCEEDEDBILLING_CARD_IN_USEBILLING_CARD_OPEN_INVOICEWEBHOOK_EVENT_EXPIREDWEBHOOK_EVENT_UNMAPPABLESTALE_BATCHTELEMETRY_UNAVAILABLEIDENTIFIER_TAKENUNKNOWN_REFERENCESIMPORT_JOB_STATEIMPORT_MAPPING_INCOMPLETEEXPERIMENT_STATEWEBHOOK_STATECOPILOT_INTENT_SETTLEDROLE_REQUIREDCAPABILITY_REQUIREDBILLING_NOTHING_TO_CANCELBILLING_ALREADY_ACTIVEBILLING_UPGRADE_REQUIREDSTRIPE_ALREADY_CONNECTEDALREADY_MEMBERALREADY_OWNERINVITATION_NOT_SENDABLEINVITATION_ALREADY_ACCEPTEDINVITATION_EMAIL_MISMATCHWEBHOOK_ENDPOINT_LIMITWEBHOOK_URL_REJECTEDTEST_DELIVERY_NOT_REDELIVERABLEINTEGRATION_ALREADY_CONNECTEDINTEGRATION_CREDENTIALS_REJECTEDSCHEDULED_ACTION_CONFLICTHOSTNAME_INVALIDORIGIN_INVALIDQUERY_REJECTEDCURRENCY_GRANT_INVALIDREFUND_NOT_POSSIBLELEGAL_URL_IN_USEADMIN_EMAIL_DOMAINADMIN_2FA_REQUIREDADMIN_SESSION_EXPIREDPROJECT_SUSPENDEDSUPPORT_ACCESS_READ_ONLY