# RevFence — complete integration guide > Written for an AI coding agent integrating RevFence on behalf of a developer. Every statement below matches the current RevFence API. Base URL: `https://api.revfence.com`. Panel: `https://app.revfence.com`. Short index: `https://www.revfence.com/llms.txt`. ## What RevFence does RevFence is a retention layer for SaaS products that bill through Stripe. When a customer presses "cancel subscription" in your product, your server asks RevFence for a session and redirects the customer to a short-lived RevFence page. The page optionally asks for a cancellation reason, then shows at most one retention offer chosen by your project's rules from five offer types: a percentage discount, a free billing period, a payment pause, a plan downgrade, or an invoice credit. If the customer accepts, RevFence applies the change on the subscription in your connected Stripe account (through Stripe Connect; your application code never touches Stripe keys for this) and shows the customer a verified summary of what was applied, read back from Stripe. If the customer declines or no offer is eligible, the page sends the customer to your own cancellation confirmation URL. RevFence never cancels a subscription. Your existing cancellation flow stays exactly where it is; RevFence sits in front of it and only ever hands the customer back. The integration is one server-side HTTP call plus a redirect: no webhooks to receive, no client-side script, no Stripe keys in your app. Some subscriptions never get an offer by design (multi-item subscriptions, metered billing, subscriptions carrying a Stripe subscription schedule); the customer is simply redirected on to your flow. If Stripe times out while an offer is being applied, RevFence never retries blindly and never tells the customer "nothing changed": the application is held for hourly reconciliation that reads the truth back from Stripe. ## The flow 1. The customer presses "cancel" in your product. 2. Your server sends `POST https://api.revfence.com/public/sessions` with the Stripe customer id, the Stripe subscription id, and an `Idempotency-Key` header. This request must be made from your server; the API key must never reach the browser. 3. RevFence responds `201` with `url` (`https://app.revfence.com/c/`). Redirect the customer to it (HTTP 302/303 from the server, or return the URL to your front end and navigate there). 4. On the RevFence page the customer sees the reason step (if enabled for the project) and the offer. - Accept: RevFence applies the offer on Stripe and then sends the customer to the project's keep URL (falling back to `returnUrl`, then the cancel URL). - Decline or no eligible offer: RevFence sends the customer to the project's cancel URL (falling back to `returnUrl`), automatically after a short delay and via a button. Your own cancellation flow runs there. - Expired session (30 minutes by default): the page shows an expired notice and nothing else; the customer returns to your product and a new cancel press opens a new session. 5. If step 2 fails in any way (any non-2xx status, a timeout, a network error), skip RevFence and send the customer straight into your own cancellation flow. Never block cancellation on RevFence. ## Prerequisites (configured in the panel) Done once in `https://app.revfence.com`, per project: 1. A project exists. 2. Stripe is connected through Stripe Connect. The connection is either test mode or live mode; the project's API keys inherit that mode. 3. Permitted offer types are approved and at least one active offer exists. Offer rules are optional: with rules, the first matching rule's offer is shown; with no rules, the most specific eligible offer is shown. 4. The cancellation confirmation URL ("cancel redirect URL") is set. Declined and offer-less customers are sent there. A keep URL (where accepting customers land) is optional. 5. Allowed return origins (optional): only needed when your `returnUrl` is on an origin different from the cancel/keep URLs. Entries are bare origins such as `https://app.example.com`; up to 20; no paths, query strings or wildcards. 6. An API key is generated (project → Setup). The key is shown once, at creation; RevFence stores only its hash. The prefix is `rf_live_` when the project's Stripe connection is in live mode and `rf_test_` when it is in test mode, decided at key creation. After switching a project from a test-mode to a live-mode Stripe connection, generate a new key. The panel's readiness checklist mirrors this list: Stripe connected, offer types approved, at least one active offer, cancel confirmation URL set, API key generated. ## Endpoint reference ### `POST /public/sessions` Opens a cancellation session and returns the page URL for one customer. Request headers: | Header | Required | Value | |---|---|---| | `Authorization` | yes | `Bearer ` — `rf_live_…` or `rf_test_…` | | `Idempotency-Key` | yes | 8–128 characters from `A-Z a-z 0-9 . _ : @ + / = -`. Unique per cancellation attempt; identical on every retry of that attempt. | | `Content-Type` | yes | `application/json` | Request body (unknown fields are ignored): | Field | Type | Rules | |---|---|---| | `customerId` | string | required; 3–120 characters, trimmed; the Stripe customer id (`cus_…`) | | `subscriptionId` | string | required; 3–120 characters, trimmed; the Stripe subscription id (`sub_…`) | | `customerEmail` | string | optional; a valid email, at most 254 characters, lowercased by the server; used for the payment-pause reminder email | | `returnUrl` | string | optional; an absolute URL, at most 500 characters, subject to the return-URL rules below | Success response `201 Created`: ```json { "sessionId": "9d6c1d8e-3f7a-4a3b-9c2e-1f0b5a7d2c44", "url": "https://app.revfence.com/c/", "expiresAt": "2026-09-30T12:34:56.000Z", "quotaExceeded": false, "idempotentReplay": false, "reason": null } ``` - `url` is bound to one session and valid until `expiresAt` (30 minutes by default). Redirect the customer to it immediately. Do not store it, do not reuse it for another customer, do not build it by hand: the token is regenerated for every session and only the response carries it. - `quotaExceeded: true` means your plan's session quota is used up. The session was recorded but the RevFence page would only redirect the customer to your cancel URL. Treat it like an error and send the customer straight into your own flow; `url` is still returned in case you prefer the extra hop. - `idempotentReplay: true` means this `Idempotency-Key` was seen before with the same body. If that session has not been opened yet (still `CREATED` and not expired), a fresh `url` is issued for it. Otherwise `url` is `null` and `reason` is `"session_already_started"`: fall back to your own flow. Error envelope, on every non-2xx response: ```json { "error": { "code": "stripe_not_connected", "message": "Connect a Stripe account first.", "requestId": "b1f4…", "fields": [] } } ``` `code` comes from a closed dictionary; `message` is safe to log; `fields` lists per-field problems (`{ field, message }`) for validation errors; quote `requestId` when contacting support. Error codes for this endpoint. In every case the integrator's runtime action is the same: continue your own cancellation flow. The right-hand column is what to fix afterwards. | HTTP | `code` | When | What to do | |---|---|---|---| | 401 | `invalid_api_key` | `Authorization: Bearer …` missing or malformed, or the key is unknown or revoked | Check `REVFENCE_API_KEY`; generate a new key in the panel if it was revoked | | 400 | `idempotency_key_required` | `Idempotency-Key` missing, shorter than 8 or longer than 128 characters, or containing other characters; `fields[0].message` is `missing`, `too_short`, `too_long` or `charset` | Fix key generation (see Idempotency rules) | | 422 | `validation_failed` | Body fails validation: missing or too short/long `customerId` / `subscriptionId`, invalid `customerEmail`, `returnUrl` not a URL or over 500 characters; `fields` names each one | Fix the payload | | 403 | `tenant_inactive` | The RevFence account is suspended | Contact RevFence support | | 409 | `stripe_not_connected` | The project has no Stripe connection or the connection is not in the connected state | Connect Stripe in the panel | | 409 | `project_paused` | The project is paused in the panel | Resume the project in the panel | | 400 | `return_url_not_allowed` | `returnUrl` is not https (http is accepted only for `localhost` outside production), carries a username/password, or its origin is not on the project's allow-list; `fields` contains `{ field: "returnUrl" }` | Add the origin under allowed return origins, or omit `returnUrl` | | 409 | `idempotency_key_reuse` | The same `Idempotency-Key` was already used with a different body | Your key generation is reusing keys across customers; fix it | | 429 | `rate_limited` | More than 60 requests in 60 seconds with this API key (per IP when no key is sent); a `Retry-After` header is set | Do not retry inside the request path; fall back | | 400 | `request_failed` | Generic transport-level rejection, for example a body that is not valid JSON | Send a JSON object with `Content-Type: application/json` | | 500 | `internal_error` | Unexpected server error | Fall back; report `requestId` if it persists | Generic `not_found` (404), `unauthorized` (401), `forbidden` (403) and `payload_too_large` (413) codes exist in the same envelope for other routes; on this endpoint a 404 means the path is wrong (`/public/sessions`, no version prefix). ### `GET /public/integration` The verification endpoint `npx revfence doctor` calls. Authenticated exactly like `POST /public/sessions` (`Authorization: Bearer `), same rate-limit tier; no body and no `Idempotency-Key`. It opens nothing and counts nothing against the quota; the only write is the key's `lastUsedAt` stamp. ```bash curl -sS https://api.revfence.com/public/integration -H "Authorization: Bearer $REVFENCE_API_KEY" ``` Response `200 OK`: ```json { "project": { "id": "9d6c1d8e-…", "name": "Acme", "slug": "acme-1a2b", "status": "LIVE" }, "stripe": { "connected": true, "livemode": false }, "offers": { "active": 2 }, "quota": { "used": 12, "limit": 100, "remaining": 88, "exceeded": false }, "allowedReturnOrigins": ["https://app.example.com"], "cancelPageBase": "https://app.revfence.com", "key": { "mode": "test" } } ``` - `project.status` is one of `DRAFT`, `LIVE`, `PAUSED`, `MAINTENANCE`, as stored. - `stripe.connected` is true only when the connection is in the connected state; `stripe.livemode` is `null` when the project has no connection record. A missing connection is reported here inside the 200, not as the session endpoint's 409: this endpoint exists to show that state. - `offers.active` is the number of the project's offers in the `ACTIVE` state. - `quota` is the same reader the panel's billing box uses. - `cancelPageBase` is the origin session URLs are built on (`/c/`), without a trailing slash. - `key.mode` is read off the key prefix (`rf_live_` → `live`, `rf_test_` → `test`); the key itself is never echoed. Errors use the standard envelope: `401 invalid_api_key` (missing/malformed header, unknown or revoked key), `403 tenant_inactive` (suspended account), `429 rate_limited`. ## Idempotency rules - The header is mandatory. Without it the request is rejected with `400 idempotency_key_required`. - Generate the key once per button press, on the server, and reuse the same value for every retry of that press (timeouts, 5xx). Generating a new key inside a retry loop opens a second session on every attempt and counts against your quota again. A good key: `cancel__`. - Keys are unique per project. A replay (same key, same body) never opens a second session: it returns the existing session, with a fresh `url` if the customer has not opened the page yet, or `url: null` and `reason: "session_already_started"` if they have. - The body fingerprint covers `customerId`, `subscriptionId`, `customerEmail` and `returnUrl`. The same key with any of them different is rejected with `409 idempotency_key_reuse`; RevFence will not silently send customer B to customer A's page. - Do not derive the key from a timestamp, and do not reuse one key for all cancellations of a subscription: a customer who cancels twice a month apart needs two sessions. ## Return URL rules `returnUrl` is optional. When present: - It must be an absolute `https://` URL (http only for `localhost`, and only outside production), at most 500 characters, without a username or password in it. - Its origin (scheme + host + port) must equal one of: the origin of the project's cancel redirect URL, the origin of the project's keep redirect URL, or an entry in the project's allowed return origins list. Only the origin is compared; the path and query string of `returnUrl` are free. - Anything else is rejected at session creation with `400 return_url_not_allowed`; the session is not opened. - Priority on redirect: the project's configured URLs come first. Cancel: cancel redirect URL, then `returnUrl`. Keep: keep redirect URL, then `returnUrl`, then the cancel redirect URL. So `returnUrl` is a per-session fallback (for example a per-tenant domain on a white-label product), not an override of what the panel configures. - If an origin is removed from the allow-list after a session was opened, the stored `returnUrl` is dropped at redirect time and the project URLs are used; the session timeline records `return_url.revoked`. - Configuring allowed origins: bare origins such as `https://billing.example.com` or `https://example.com:8443`; at most 20; no path, query, fragment or wildcard. ## Environment variables | Variable | Value | |---|---| | `REVFENCE_API_KEY` | The project API key from the panel (`rf_live_…` in production, `rf_test_…` in development). Server-side only. Never commit it; never expose it with a public prefix such as `NEXT_PUBLIC_`, `VITE_` or `REACT_APP_`. | | `REVFENCE_API_URL` | Optional. Defaults to `https://api.revfence.com`. No trailing slash. | | `REVFENCE_RETURN_URL` | Optional; read by code generated with `npx revfence init` as the session's `returnUrl`. Must satisfy the return-URL rules below. Leave unset to let the panel's cancel/keep URLs drive the redirect. | Add `REVFENCE_API_KEY=` to `.env.example` with an empty value and the real key to the untracked `.env` / `.env.local` and to the production secret store. ## Examples All examples do the same four things: build one idempotency key per press, call the endpoint with a short timeout, redirect to `url` on success, and fall back to the app's own cancellation route on anything else. ### TypeScript (fetch, any runtime) ```ts import { randomUUID } from "node:crypto"; const REVFENCE_API_URL = process.env.REVFENCE_API_URL ?? "https://api.revfence.com"; const REVFENCE_API_KEY = process.env.REVFENCE_API_KEY!; /** Returns the RevFence page URL, or null when the app should run its own cancellation flow. */ export async function openRevFenceSession(input: { customerId: string; // cus_… subscriptionId: string; // sub_… customerEmail?: string; returnUrl?: string; // https URL on an allowed origin }): Promise { // One key per button press; reuse the SAME value if you retry this call. const idempotencyKey = `cancel_${input.subscriptionId}_${randomUUID()}`; try { const res = await fetch(`${REVFENCE_API_URL}/public/sessions`, { method: "POST", headers: { Authorization: `Bearer ${REVFENCE_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify(input), signal: AbortSignal.timeout(5000), }); if (!res.ok) return null; // 4xx/5xx: run your own cancellation flow const data = (await res.json()) as { url: string | null; quotaExceeded: boolean; }; if (!data.url || data.quotaExceeded) return null; return data.url; } catch { return null; // timeout or network error: run your own cancellation flow } } ``` ### Node / Express ```js const { randomUUID } = require("node:crypto"); const REVFENCE_API_URL = process.env.REVFENCE_API_URL || "https://api.revfence.com"; app.post("/account/cancel", requireUser, async (req, res) => { const user = req.user; // has stripeCustomerId and stripeSubscriptionId const fallback = "/account/cancel/confirm"; // your existing cancellation page try { const r = await fetch(`${REVFENCE_API_URL}/public/sessions`, { method: "POST", headers: { Authorization: `Bearer ${process.env.REVFENCE_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `cancel_${user.stripeSubscriptionId}_${randomUUID()}`, }, body: JSON.stringify({ customerId: user.stripeCustomerId, subscriptionId: user.stripeSubscriptionId, customerEmail: user.email, }), signal: AbortSignal.timeout(5000), }); if (!r.ok) return res.redirect(303, fallback); const { url, quotaExceeded } = await r.json(); return res.redirect(303, url && !quotaExceeded ? url : fallback); } catch { return res.redirect(303, fallback); } }); ``` ### Next.js route handler (App Router) ```ts // app/api/billing/cancel/route.ts import { NextResponse } from "next/server"; import { randomUUID } from "node:crypto"; import { getCurrentUser } from "@/lib/auth"; // your own helper const REVFENCE_API_URL = process.env.REVFENCE_API_URL ?? "https://api.revfence.com"; export async function POST(request: Request) { const user = await getCurrentUser(request); if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); const fallback = new URL("/account/cancel/confirm", request.url); try { const r = await fetch(`${REVFENCE_API_URL}/public/sessions`, { method: "POST", headers: { Authorization: `Bearer ${process.env.REVFENCE_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `cancel_${user.stripeSubscriptionId}_${randomUUID()}`, }, body: JSON.stringify({ customerId: user.stripeCustomerId, subscriptionId: user.stripeSubscriptionId, customerEmail: user.email, returnUrl: new URL("/account", request.url).toString(), }), signal: AbortSignal.timeout(5000), }); if (!r.ok) return NextResponse.redirect(fallback, 303); const { url, quotaExceeded } = await r.json(); return NextResponse.redirect(url && !quotaExceeded ? url : fallback, 303); } catch { return NextResponse.redirect(fallback, 303); } } ``` The route runs on the server, so `REVFENCE_API_KEY` stays private. The cancel button submits a `POST` to this route (a form or `fetch` followed by `window.location.assign` of the returned redirect target). ### Ruby ```ruby require "net/http" require "json" require "securerandom" def revfence_session_url(customer_id:, subscription_id:, email: nil, return_url: nil) base = ENV.fetch("REVFENCE_API_URL", "https://api.revfence.com") uri = URI("#{base}/public/sessions") req = Net::HTTP::Post.new(uri) req["Authorization"] = "Bearer #{ENV.fetch("REVFENCE_API_KEY")}" req["Content-Type"] = "application/json" req["Idempotency-Key"] = "cancel_#{subscription_id}_#{SecureRandom.uuid}" body = { customerId: customer_id, subscriptionId: subscription_id } body[:customerEmail] = email if email body[:returnUrl] = return_url if return_url req.body = body.to_json res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 3, read_timeout: 5) { |h| h.request(req) } return nil unless res.code == "201" data = JSON.parse(res.body) return nil if data["quotaExceeded"] || data["url"].nil? data["url"] rescue StandardError nil # run your own cancellation flow end ``` ### PHP ```php $customerId, 'subscriptionId' => $subscriptionId]; if ($email) { $body['customerEmail'] = $email; } if ($returnUrl) { $body['returnUrl'] = $returnUrl; } $idempotencyKey = 'cancel_' . $subscriptionId . '_' . bin2hex(random_bytes(16)); $ch = curl_init($base . '/public/sessions'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 5, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('REVFENCE_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . $idempotencyKey, ], CURLOPT_POSTFIELDS => json_encode($body), ]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($raw === false || $status !== 201) { return null; } $data = json_decode($raw, true); if (empty($data['url']) || !empty($data['quotaExceeded'])) { return null; } return $data['url']; } // $url = revfenceSessionUrl($cus, $sub, $email); header('Location: ' . ($url ?? '/account/cancel/confirm'), true, 303); ``` ### Python (`requests`) ```python import os import uuid import requests REVFENCE_API_URL = os.environ.get("REVFENCE_API_URL", "https://api.revfence.com") def revfence_session_url(customer_id: str, subscription_id: str, email: str | None = None, return_url: str | None = None) -> str | None: """Return the RevFence page URL, or None when the app should run its own cancellation flow.""" body = {"customerId": customer_id, "subscriptionId": subscription_id} if email: body["customerEmail"] = email if return_url: body["returnUrl"] = return_url try: r = requests.post( f"{REVFENCE_API_URL}/public/sessions", json=body, headers={ "Authorization": f"Bearer {os.environ['REVFENCE_API_KEY']}", "Idempotency-Key": f"cancel_{subscription_id}_{uuid.uuid4()}", }, timeout=(3, 5), ) except requests.RequestException: return None if r.status_code != 201: return None data = r.json() if not data.get("url") or data.get("quotaExceeded"): return None return data["url"] ``` ### curl ```bash curl -sS -X POST "https://api.revfence.com/public/sessions" \ -H "Authorization: Bearer $REVFENCE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: cancel_sub_123_$(uuidgen)" \ -d '{"customerId":"cus_123","subscriptionId":"sub_123","customerEmail":"jane@example.com"}' ``` ## Test it 1. Use an `rf_test_` key. It comes from a project whose Stripe connection is in test mode; it works against the same `https://api.revfence.com` endpoint. `customerId` and `subscriptionId` must be real ids from that connected test-mode Stripe account, because the page reads the subscription from Stripe before deciding on an offer. 2. Verify the key and the setup: `curl -sS https://api.revfence.com/public/integration -H "Authorization: Bearer $REVFENCE_API_KEY"`, or run `npx revfence doctor`. Both report the project and its status, the Stripe connection, the number of active offers, the quota, the allowed return origins and the key mode. 3. Open a session with the curl example above and visit the returned `url` in a browser. Accept and decline once each; check that you land on your keep and cancel URLs. 4. Quota-free tests live in the panel: project → Setup → "Test session" opens a session that never counts against the quota and stays out of analytics. Sessions opened through the API count against the quota when they settle, whichever key prefix you use, so use the panel button for repeated manual checks. 5. A session ends in one of `APPLIED`, `DECLINED`, `NO_OFFER`, `EXPIRED`, `FAILED`, `QUOTA_EXCEEDED`; the panel's sessions list shows each one with its timeline. ## Go-live checklist - [ ] The panel readiness checklist is complete: Stripe connected (live mode), offer types approved, at least one active offer, cancel confirmation URL set, API key generated. - [ ] `REVFENCE_API_KEY` in production is an `rf_live_` key created after the live-mode Stripe connection; the test key stays in development. - [ ] The session call runs on the server only, with a timeout, and every failure path (non-2xx, `quotaExceeded`, `url: null`, timeout, exception) sends the customer into your own cancellation flow. - [ ] One `Idempotency-Key` per button press, reused on retries of that press, never derived from a timestamp. - [ ] `returnUrl`, if used, is https and on the cancel/keep URL origin or a listed allowed origin. Removing a domain from the list later is safe (the project URLs take over). - [ ] Project status is not paused. `DRAFT` and `LIVE` open sessions; `MAINTENANCE` opens them but shows a maintenance notice and applies nothing; `PAUSED` returns `409 project_paused`. - [ ] Your own cancellation page still works when reached directly, since RevFence redirects there. - [ ] `npx revfence doctor` reports no missing step. ## Security notes - The API key is a server secret. Never call `/public/sessions` from a browser, a mobile app or an edge function that ships the key to the client. Never commit it; keep it out of `NEXT_PUBLIC_*`/`VITE_*`/`REACT_APP_*` variables, client bundles, logs and error reports. - Rotate by creating a new key in the panel and revoking the old one; a revoked key returns `401 invalid_api_key` immediately. - Send only what the endpoint needs: the Stripe customer id, the subscription id, and optionally the customer's email (used for the payment-pause reminder). Do not put names, addresses, plan details or internal ids into the request; RevFence reads subscription details from Stripe itself. - RevFence stores the caller's IP only as a truncated hash and the user agent truncated to 200 characters, for the session timeline. The page token is stored hashed; the plain token exists only in the `url` you receive. - RevFence applies offers through your Stripe Connect authorisation; your application never needs to send a Stripe key to RevFence. - The RevFence page shows a fixed disclosure to the customer stating that RevFence operates it and what it processes. ## Using the CLI - `npx revfence init` — detects the framework in the current repository (Next.js (App and Pages Router), Express, Fastify, Koa, Hono, NestJS, Rails, Laravel, Django, Flask and FastAPI, with a plain TypeScript/JavaScript, Ruby, PHP or Python helper when no framework is recognised), shows the diff of the files it will write, then writes the server-side session call for that framework (the call, the fallback to your cancellation route and the same error handling as this guide) and stores `REVFENCE_API_KEY` in `.env.local` when it exists, else `.env`, else a new `.env.local`. It writes the key nowhere else; add the empty `REVFENCE_API_KEY=` line to `.env.example` yourself. It validates the key format (`rf_live_`/`rf_test_` plus at least 20 URL-safe characters) before writing anything. - `npx revfence doctor` — reads `REVFENCE_API_KEY` (and `REVFENCE_API_URL` when set) from the process environment, then `.env.local`, then `.env`; calls `GET /public/integration`; and reports the project and its status, the Stripe connection, the number of active offers, the quota, the allowed return origins and the key mode, with what is still missing before go-live. - `npx revfence skill` — copies the RevFence Claude Code skill (`SKILL.md`) and Cursor rule (`revfence.mdc`) shipped with the package into the repository's Claude Code skills and Cursor rules folders, so an AI coding assistant integrates RevFence following this guide. ## FAQ **Does RevFence cancel subscriptions?** No, never. It only applies an accepted retention offer (discount, free period, pause, downgrade, invoice credit) on Stripe. Declined customers are redirected to your cancellation confirmation URL, where your own flow cancels as it always did. **What if the RevFence API is down or slow?** Fall back. Any non-2xx status, timeout or exception from `POST /public/sessions` means "run your own cancellation flow now". Use a short timeout (about 5 seconds) and never make the cancel button depend on RevFence. **Do I need webhooks?** No. The whole integration is the one server-side call and the redirect. Offer outcomes are visible in the panel's sessions list; the customer's subscription changes are visible in Stripe as usual. **Can I call the endpoint from the browser?** No. The API key would be exposed, and the origin check is not a substitute for a secret. Call it from your server and redirect. **Why do I get `409 stripe_not_connected` with a valid key?** The project's Stripe connection is missing or no longer in the connected state. Reconnect Stripe in the panel. The same code is returned by the panel's own test button. **Why `400 return_url_not_allowed`?** The `returnUrl` origin is not the cancel/keep URL origin and is not in the allowed return origins list, or the URL is not https, or it contains credentials. Add the origin (bare, no path) in the panel or omit `returnUrl`. **What does `idempotentReplay: true` with `url: null` mean?** You retried a request whose session the customer already opened (or which already settled). There is nothing to redirect to; send the customer to your own flow. **Does a test key consume quota?** Sessions opened through the API count against the quota when they settle, with either key prefix. Only sessions opened from the panel's test button are free. **Can I pass extra data (plan name, user id)?** Unknown fields are ignored. RevFence reads the subscription from Stripe; put per-customer context in Stripe, not in the request. **What happens when the session expires?** The page shows an expired notice and does not redirect anywhere; the session settles as `EXPIRED`. The customer goes back to your product, and a new cancel press opens a new session with a new key.