Skip to content

Add Payments on Railway, Vercel, Render, Netlify, Cloudflare

One Flint integration runs on every host. Set three things per environment: API key, webhook secret, and webhook URL, with test keys everywhere but production.
Deploying16 min readBy Flint Pay, Product & API Team

Your app runs locally, the agent wired up checkout, and the next deploy goes to Railway, Vercel, Render, Netlify, or Cloudflare Workers. The payment code doesn't change between those hosts. What changes is where each environment keeps its secrets, how a function hands you the request body, what URL a preview deploy gets, and what stands between that URL and an incoming webhook.

The short answer#

A Flint integration needs three settings in each environment your app runs in:

EnvironmentFLINT_API_KEYWebhook URL registered with FlintFLINT_WEBHOOK_SECRET
Localflint_test_...None: flint listen forwards events to localhostThe secret flint listen prints
Preview or pull requestflint_test_...Optional, at the preview's stable URLThat endpoint's whsec_...
Stagingflint_test_...Your staging URL, registered with the test keyThat endpoint's whsec_...
Productionflint_live_...Your production domain, registered with the live keyThat endpoint's whsec_...

The live key exists in exactly one place. Every other environment runs against a sandbox, where test cards work, nothing is charged, and sandbox keys are free.

That table stays this small because the billing runs in Flint. Renewals, retries on declined cards, receipts, and the account page where subscribers cancel all happen on Flint's side, so your host runs no scheduled job that charges cards and no worker that retries them. It serves a checkout redirect and one webhook route. Create a free sandbox and the same code deploys to any of the five hosts. How to take payments in a vibe-coded app covers the integration itself: the plan, hosted checkout, and the access logic.

The pattern that holds on every host#

One key per mode, and the live key only in production#

A Flint key's prefix decides its mode. A flint_test_ key is bound to a sandbox and only sees that sandbox's data; a flint_live_ key moves real money. Requests go to the same base URL with the same shapes either way, so the code is identical in every environment and the key in each one decides the mode.

A webhook endpoint belongs to the mode and sandbox of the key that created it and receives only that environment's events. Register the production endpoint with the live key and the staging endpoint with a test key, and each gets its own whsec_ secret. A sandbox endpoint never receives a live payment, and the live endpoint never receives a test one. Endpoints in the same sandbox all receive the sandbox events they subscribe to, so if previews and staging share a sandbox, a handler should return 200 for a customer it doesn't recognize. Sandboxes covers giving staging its own.

Neither value belongs in a variable your frontend can read. Next.js inlines any NEXT_PUBLIC_ variable into the JavaScript it sends to the browser, and Vite does the same for VITE_ variables. Name them FLINT_API_KEY and FLINT_WEBHOOK_SECRET and read them only in server code. API key security lists the other prefixes to avoid.

Add a guard so a misconfigured environment fails at boot instead of charging real cards. APP_ENV is a variable you set yourself in each environment:

TypeScript
// Refuse to start when a live key reaches anything but production.
const key = process.env.FLINT_API_KEY ?? "";
if (process.env.APP_ENV !== "production" && key.startsWith("flint_live_")) {
  throw new Error("flint_live_ key outside production");
}

The reverse mistake, a test key in production, raises no error: payments look like they succeed and no money moves. Every authenticated Flint response carries a Flint-Mode header, test or live, so assert Flint-Mode: live in your production deploy check.

Changing a variable doesn't change a running deploy#

On all five hosts, a new value reaches your app only through a new deploy. Vercel and Netlify apply it to the next deployment, Railway stages the change until you deploy it, Render's "Save only" waits for the next deploy, and Cloudflare's wrangler secret put deploys a new version on the spot. After you rotate a key or paste a webhook secret, confirm a fresh deploy picked it up.

One handler, wrapped per runtime#

Signature verification needs the exact bytes Flint sent, before anything parses them. Flint follows the Standard Webhooks spec, so the standardwebhooks package on npm verifies the webhook-id, webhook-timestamp, and webhook-signature headers with your whsec_ secret. It's plain JavaScript with no Node crypto dependency, so it runs in Node functions and in Workers alike.

Put the part that's the same everywhere in one module. Each host section wraps it for that runtime:

TypeScript
// lib/flint-events.ts
import { setPlanAccess } from "./access"; // your code: turns a customer's access on or off

export type FlintEvent = {
  webhook_event_id: string;
  event_type: string;
  test?: boolean;
  data: { object: { subscription_id?: string } };
};

export async function handleFlintEvent(event: FlintEvent, apiKey: string) {
  // Test events from POST /v1/webhook-endpoints/{id}/test-events carry placeholder IDs.
  if (event.test) return;
  const subscriptionId = event.data.object.subscription_id;
  if (!event.event_type.startsWith("subscription.") || !subscriptionId) return;

  // Deliveries repeat and arrive out of order, so act on the current state.
  const response = await fetch(
    `https://api.withflintpay.com/v1/subscriptions/${subscriptionId}`,
    { headers: { Authorization: `Bearer ${apiKey}` } },
  );
  if (!response.ok) throw new Error(`Flint returned ${response.status}`);
  const { data: subscription } = (await response.json()) as {
    data: { customer_id: string; status: string };
  };
  await setPlanAccess(subscription.customer_id, subscription.status);
}

Flint waits 10 seconds for a 2xx. One fetch and one database write fit easily; anything slower belongs in a queue. The request limits on Vercel, Netlify, Railway, and Workers are all longer, so the usual failures are a sleeping server and a URL Flint can't reach. Add a paywall to your app covers what setPlanAccess should do with each status.

Register an endpoint per environment#

With the environment's own key, register its URL:

cURL
curl -X POST https://api.withflintpay.com/v1/webhook-endpoints \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $FLINT_API_KEY" \
  -H "Idempotency-Key: webhook-staging-v1" \
  -d '{
    "url": "https://staging.yourapp.com/webhooks/flint",
    "enabled_events": [
      "subscription.created",
      "subscription.activated",
      "subscription.past_due",
      "subscription.canceled",
      "subscription.plan_changed"
    ],
    "description": "Staging"
  }'

Store data.secret from the response as that environment's FLINT_WEBHOOK_SECRET; Flint shows it once. The URL must be public HTTPS, and Flint doesn't follow redirects, so register the final address: the www version if your apex domain redirects there, with the trailing slash or without it exactly as your app serves it. When a preview URL changes, update the endpoint with PATCH /v1/webhook-endpoints/{webhook_endpoint_id} instead of registering another.

Then send a signed test event to prove the route, the secret, and the URL work together:

cURL
curl -X POST https://api.withflintpay.com/v1/webhook-endpoints/whep_1kmn0aExample/test-events \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $FLINT_API_KEY" \
  -d '{"event_type": "subscription.activated"}'

"attempt_status": "delivered" means the handler received, verified, and acknowledged it. A failed attempt comes back with the status code your app returned.

Railway#

Variables. Every Railway project starts with a production environment, and every change to a service is scoped to one environment, so a staging environment holds its own FLINT_API_KEY. Set variables in the service's Variables tab, or with the CLI:

Shell
railway variable set FLINT_API_KEY=flint_test_... --environment staging --service web

Railway stages variable changes until you deploy them; the CLI's set triggers the deploy unless you pass --skip-deploys. A Railway service reads them from process.env.

PR environments. When you enable PR environments, Railway creates one for each pull request and deletes it when the PR merges or closes. Each replicates its base environment, variables included, and the base is production unless you change it in project settings. Left at the default, every pull request boots with your live key. Two fixes: make staging the base environment, or seal the production FLINT_API_KEY and FLINT_WEBHOOK_SECRET (the variable's menu, then Seal). Sealed variables aren't copied to PR environments, so each one starts without them and you give it test values.

Webhook URL. Generate a domain under the service's Settings, then Networking. PR environments get new Railway-provided URLs of their own, one per pull request. Register production against your custom domain or the production Railway domain, staging against staging's, and test webhook flows on staging unless a pull request needs them.

Raw body. Railway runs your server as it is, so this Express route works unchanged, and so does a Next.js App Router route like the one in the Vercel section:

TypeScript
// server.ts: runs unchanged on Railway and Render
import express from "express";
import { Webhook } from "standardwebhooks";
import { handleFlintEvent, type FlintEvent } from "./lib/flint-events";

const app = express();
const webhook = new Webhook(process.env.FLINT_WEBHOOK_SECRET!);

// Register this route before app.use(express.json()): verification needs the raw bytes.
app.post("/webhooks/flint", express.raw({ type: "application/json" }), async (req, res) => {
  let event: FlintEvent;
  try {
    event = webhook.verify(req.body, {
      "webhook-id": req.get("webhook-id") ?? "",
      "webhook-timestamp": req.get("webhook-timestamp") ?? "",
      "webhook-signature": req.get("webhook-signature") ?? "",
    }) as FlintEvent;
  } catch {
    res.status(400).send("invalid signature");
    return;
  }
  try {
    await handleFlintEvent(event, process.env.FLINT_API_KEY!);
    res.sendStatus(200);
  } catch {
    res.sendStatus(500); // Flint retries the delivery
  }
});

app.use(express.json());
app.listen(Number(process.env.PORT ?? 3000));

Timeouts and sleeping. Railway HTTP requests can run up to 15 minutes, so the route has room. Serverless mode is the risk: a sleeping service wakes on the first request, and Railway says that request may get a 502. Flint retries a failed delivery, up to 9 attempts over 71 to 86 hours, but leave Serverless off for the production service that receives webhooks.

Vercel#

Variables. Each Vercel environment variable applies to Production, Preview, Development, or any mix, and a Preview value can target a single Git branch, which overrides the all-branches value. From the CLI:

Shell
vercel env add FLINT_API_KEY production   # paste the flint_live_ key
vercel env add FLINT_API_KEY preview      # paste a flint_test_ key
vercel env add FLINT_API_KEY development  # paste a flint_test_ key

The CLI stores Production and Preview values as Secret by default, so they're write-only after you save them. Changes apply only to new deployments, so redeploy after setting them. vercel env pull writes the Development values into a local .env file.

Raw body. In a Next.js App Router route handler, await request.text() returns the raw body, and the route in the how-to uses it. A Vite or other non-Next app on Vercel needs a function in the root api/ directory. Use the Web Request signature, because the older (req, res) signature's request.body helper hands you JSON that's already parsed:

TypeScript
// api/flint-webhook.ts (in Next.js: app/api/webhooks/flint/route.ts)
import { Webhook } from "standardwebhooks";
import { handleFlintEvent, type FlintEvent } from "../lib/flint-events";

export async function POST(request: Request) {
  const body = await request.text(); // the exact bytes, before any parsing
  let event: FlintEvent;
  try {
    event = new Webhook(process.env.FLINT_WEBHOOK_SECRET!).verify(
      body,
      Object.fromEntries(request.headers),
    ) as FlintEvent;
  } catch {
    return new Response("invalid signature", { status: 400 });
  }
  try {
    await handleFlintEvent(event, process.env.FLINT_API_KEY!);
  } catch {
    return new Response("retry", { status: 500 }); // Flint retries the delivery
  }
  return new Response(null, { status: 200 });
}

In the Next.js Pages Router, add export const config = { api: { bodyParser: false } } to the API route and read the stream yourself.

Preview URLs. Every commit gets a unique URL, <project>-<unique-hash>-<scope>.vercel.app, that never points anywhere else. Every branch also gets <project>-git-<branch>-<scope>.vercel.app, which always serves the branch's latest deployment. Register the branch URL if a preview needs webhooks; a commit URL goes stale on the next push.

Deployment Protection. Vercel's guide says Vercel Authentication is on by default for every deployment except the most recent production one. A request without a Vercel login gets redirected to a login page, and Flint doesn't follow redirects, so deliveries to a protected preview fail. Vercel's documented fix for third-party webhooks is Protection Bypass for Automation: create a bypass secret in project settings and add it to the registered URL as ?x-vercel-protection-bypass=<secret>. Use a preview-only secret, since anyone who can read the endpoint can read its URL. Standard Protection also covers *.vercel.app deployment URLs of production builds, so register your production domain, not a generated URL.

Timeouts. Vercel Functions default to a 300-second maximum duration on every plan, so Flint's 10 seconds is the limit that matters.

Render#

Variables. In the Render dashboard, open the service, then Environment, then Environment Variables. Saving offers "Save, rebuild, and deploy", "Save and deploy", or "Save only", and "Save only" leaves the running service on the old values until its next deploy. Environment groups share variables across services, but a value set on the service itself always wins over a linked group.

Previews. A pull request preview is a temporary copy of the service with its own onrender.com URL, and Render deletes it when the PR merges or closes. When Render creates it, it copies every setting from the base service, secrets included. A preview of your production service starts with the live key, so change its FLINT_API_KEY and FLINT_WEBHOOK_SECRET to test values. Render sets IS_PULL_REQUEST to true on a preview, which also works for the boot guard. Blueprint preview environments handle this differently: variables declared with sync: false aren't copied to previews, and an environment group you manage outside the Blueprint can give every preview its test key.

Raw body. The Express server in the Railway section runs unchanged on Render. Bind to the port in PORT, which Render sets to 10000 by default.

Timeouts and sleeping. A free Render web service spins down after 15 minutes without traffic and takes about a minute to wake, well past Flint's 10 seconds. The delivery that wakes it fails and Flint retries it, so run the production service that receives webhooks on a paid instance.

Netlify#

Variables. A Netlify variable can hold a different value per deploy context: Production, Deploy Previews, Branch deploys, Local development, or one specific branch. From the CLI:

Shell
netlify env:set FLINT_API_KEY flint_live_... --context production --secret
netlify env:set FLINT_API_KEY flint_test_... --context deploy-preview branch-deploy dev

Set Flint values in the UI, CLI, or API. Variables declared in netlify.toml aren't available to functions, so a key placed there reads as undefined at runtime. Each deploy keeps the values that were set when it was built; create a new deploy to apply a change.

Raw body. Netlify Functions receive a standard Request, so await req.text() is the raw body:

TypeScript
// netlify/functions/flint-webhook.mts
import type { Config } from "@netlify/functions";
import { Webhook } from "standardwebhooks";
import { handleFlintEvent, type FlintEvent } from "../../lib/flint-events";

export default async (req: Request) => {
  const body = await req.text(); // the exact bytes, before any parsing
  let event: FlintEvent;
  try {
    event = new Webhook(process.env.FLINT_WEBHOOK_SECRET!).verify(
      body,
      Object.fromEntries(req.headers),
    ) as FlintEvent;
  } catch {
    return new Response("invalid signature", { status: 400 });
  }
  try {
    await handleFlintEvent(event, process.env.FLINT_API_KEY!);
  } catch {
    return new Response("retry", { status: 500 }); // Flint retries the delivery
  }
  return new Response(null, { status: 200 });
};

export const config: Config = { path: "/webhooks/flint", method: ["POST"] };

With a custom path, the function answers only there, not at /.netlify/functions/flint-webhook. A Next.js app on Netlify can keep its App Router route instead.

Preview URLs. A Deploy Preview for pull request #42 lives at deploy-preview-42--yoursite.netlify.app, and a branch deploy of staging at staging--yoursite.netlify.app. Both stay the same as you push new commits, so either works as a registered URL. Every deploy also gets a permalink that starts with its deploy ID and never changes content; don't register that one. If you turn on password protection for non-production deploys, Flint's deliveries are blocked too, so receive webhooks on a branch deploy you leave unprotected, such as staging.

Timeouts. A synchronous Netlify Function can run for 60 seconds, more than Flint's 10-second budget.

Cloudflare Workers#

Secrets. Workers read secrets from the env argument, not from process.env (unless you turn on Node.js compatibility). Set them with Wrangler, which prompts for the value:

Shell
npx wrangler secret put FLINT_API_KEY                # production Worker: the flint_live_ key
npx wrangler secret put FLINT_API_KEY --env staging  # staging Worker: a flint_test_ key

wrangler secret put deploys a new version as soon as it saves. An [env.staging] section in your Wrangler config deploys as a separate Worker named <name>-staging, and secrets don't carry over between environments, so each one needs its own. For local development, put test values in .dev.vars next to the Wrangler config and keep that file out of Git.

Raw body. A Worker's fetch handler gets a standard Request, and standardwebhooks runs without Node APIs:

TypeScript
// src/index.ts, with flint-events.ts next to it
import { Webhook } from "standardwebhooks";
import { handleFlintEvent, type FlintEvent } from "./flint-events";

interface Env {
  FLINT_API_KEY: string;
  FLINT_WEBHOOK_SECRET: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    if (request.method !== "POST" || url.pathname !== "/webhooks/flint") {
      return new Response("not found", { status: 404 });
    }
    const body = await request.text(); // the exact bytes, before any parsing
    let event: FlintEvent;
    try {
      event = new Webhook(env.FLINT_WEBHOOK_SECRET).verify(
        body,
        Object.fromEntries(request.headers),
      ) as FlintEvent;
    } catch {
      return new Response("invalid signature", { status: 400 });
    }
    try {
      await handleFlintEvent(event, env.FLINT_API_KEY);
    } catch {
      return new Response("retry", { status: 500 }); // Flint retries the delivery
    }
    return new Response(null, { status: 200 });
  },
};

Do the work before returning. Work passed to ctx.waitUntil() runs after the 200 has gone out, so if it fails, Flint has no reason to retry.

Preview URLs. Two different features, with different secrets:

  • Version URLs (<version-prefix>-<worker>.<subdomain>.workers.dev) run a version of a Worker with that Worker's own configuration. A version URL of your production Worker uses the live key, so don't test checkout on one.
  • Previews, created with npx wrangler preview, don't inherit production settings. Give them a test key, and they get a stable <preview-name>-<worker>.<subdomain>.workers.dev URL you can register.

Both are public by default. If you protect them with Cloudflare Access, Flint can't sign in, so register an unprotected URL such as the staging Worker's.

Limits. An HTTP-triggered Worker has no wall-clock limit while the client stays connected. CPU time is capped at 10 ms per request on the Free plan and 30 seconds by default on Paid, and time spent waiting on fetch doesn't count toward it.

Pitfalls#

SymptomCauseFix
Every delivery fails verificationA body parser ran first: express.json() registered before the route, the Pages Router's default bodyParser, or Vercel's (req, res) helpersVerify the raw bytes: express.raw() on the route, request.text() in Web handlers
Deliveries to a preview stop after a pushThe endpoint points at a per-commit or per-deploy URLRegister the stable branch URL, or PATCH the endpoint's url
Deliveries fail with a redirectA login wall, password protection, or an apex-to-www redirect sits in front of the routeRegister the final public URL; on Vercel, add the bypass parameter
404 on a plan or product in productionA plan_... or prod_... ID from the sandbox is in production configRe-create it with the live key and update the variable
A preview charged a real cardThe preview copied production's variables, live key includedSet test values on previews; seal the key on Railway; add the boot guard
Signature fails only in one environmentThat environment holds another endpoint's whsec_ secretEach endpoint has its own; copy the one from that environment's registration
Payments succeed but no money arrivesA test key reached production; responses say Flint-Mode: testDeploy the flint_live_ key and assert Flint-Mode: live
The first delivery after a quiet hour failsThe service was asleep (Railway Serverless, Render Free)Keep the production service awake; Flint retries in the meantime

Local development without a tunnel#

Flint won't deliver to localhost, and you don't need a tunnel to test locally. The Flint CLI streams your sandbox's events and posts each one to your local server, signed:

Shell
flint login
flint listen --print-secret   # set this as FLINT_WEBHOOK_SECRET in your local env
flint listen --forward-to http://localhost:3000/webhooks/flint

Put the printed secret wherever your stack reads local values: .env.local for Next.js, .dev.vars for Wrangler, or your host's local-development context. The CLI reuses the same secret across runs. Then run a sandbox checkout with 4242 4242 4242 4242 and watch the forwarded subscription.created arrive. flint listen is for development only; every deployed environment gets a registered endpoint.

The prompt#

Paste this into Claude Code, Codex, or Cursor with the Flint docs MCP server connected, and replace the host:

Text
This app deploys to [Railway | Vercel | Render | Netlify |
Cloudflare Workers]. Wire Flint payments per environment. Use
the Flint docs MCP server for every Flint endpoint, and this
host's own docs for variables and previews.

1. Read FLINT_API_KEY and FLINT_WEBHOOK_SECRET in server code
   only. Never give them a NEXT_PUBLIC_ or VITE_ prefix.
2. List every environment this host gives the app (local,
   preview or pull request, staging, production) and which key
   each holds: flint_live_ in production only, flint_test_
   everywhere else. Tell me where to paste each value; don't
   ask me to paste keys into this chat.
3. Add a webhook route that reads the raw request body,
   verifies it with the standardwebhooks npm package, fetches
   the subscription, sets access from its status, and returns
   200 within 10 seconds.
4. For each environment that receives webhooks, give me the
   exact public HTTPS URL to register (no redirect, no login
   wall) and the curl call that registers it with that
   environment's key.
5. Add a boot check that refuses to start when a flint_live_
   key is present outside production.
6. Run flint listen --forward-to against the local route, run
   a sandbox checkout, and show me the delivery succeeded.
   After each deploy, send a test event to that environment's
   endpoint and show me attempt_status.

Ship it#

The host decides where three values live. Flint holds everything else: the subscription, its renewals and retries, receipts, refunds, and the account page where subscribers manage billing. If your app stores users in Supabase, add payments to a Supabase app covers the database side, and the go-live checklist covers what to check before the first real charge.

Sign up for Flint, set the test key on every environment, and paste the prompt.

FAQ#

How do I receive webhooks on Railway?#

Run your server as usual, generate a public domain under the service's Settings, then Networking, and register that domain's /webhooks/flint URL as a Flint webhook endpoint with that environment's key. Store the whsec_ secret from the response as a variable in the same Railway environment, verify the raw body before parsing it, and leave Serverless off on the production service, since a sleeping service's first request can return a 502.

Why does my Vercel webhook signature verification fail?#

Usually the body was parsed before you verified it. Read it with await request.text() in an App Router route handler or a Web-signature function in api/; in the Pages Router, turn off bodyParser. If deliveries never reach your code at all, Deployment Protection is redirecting them to a login page: add Vercel's x-vercel-protection-bypass query parameter to the registered URL. A secret copied from another environment's endpoint also fails, because every endpoint signs with its own.

Can I use test keys on preview deploys?#

Yes, and previews should never hold anything else. Set a flint_test_ key as the Preview value on Vercel, the deploy-preview context on Netlify, and the preview's own variables on Render and Railway, both of which copy production's values into a new preview unless you change them. Test keys are free and test cards work against them.

Do I need a separate webhook endpoint per environment?#

Yes, one for each environment that receives webhooks. An endpoint belongs to the mode and sandbox of the key that registered it and receives only that environment's events, so production needs an endpoint registered with the live key and staging needs one registered with a test key. Each has its own whsec_ secret. Local development needs none, because flint listen forwards events to localhost.

Does Cloudflare Workers support Standard Webhooks verification?#

Yes. The standardwebhooks npm package computes the HMAC in plain JavaScript, with no dependency on Node's crypto module, so it runs in a Worker. Pass it await request.text() and the request's headers, read the secret from env.FLINT_WEBHOOK_SECRET, and finish your work before returning the 200.

Sources#

Third-party facts above were checked on 2026-10-26.