Render

Render hosts the whole stack from one Blueprint, and runs subscription pauses as Render Workflow tasks with their own retries and run log.

What the Blueprint creates

render.yaml at the repo root describes four resources. Render creates them together and wires their URLs and shared secrets.

ResourceTypeWhat it runs
offersdk-apiWeb service (Docker)The Offer API from api/Dockerfile. Health check on /health, which also checks the database.
offer-appWeb service (Node)The dashboard. Built with pnpm through corepack, health check on /api/health.
offersdk-workflowsWorkflow (Node)The pause tasks in workflows/src/index.ts. Needs Node 24 or newer.
offersdk-dbPostgres 17All data, including dashboard users and sessions. Its IP allow list is empty, so only internal connections work.

Each service has a build filter, so a push that only touches api/ doesn't rebuild the dashboard, and the reverse.

Deploy

Use the one-click deploy link, or choose New → Blueprint in Render and pick your fork. On the first deploy Render asks for the values marked sync: false:

VariableServiceValue
ADMIN_API_KEYoffersdk-apiA long random secret, for example from openssl rand -hex 32.
APP_URLoffersdk-apiThe dashboard's public URL.
OFFER_API_URLoffer-app, offersdk-workflowsThe API's public URL.
ANTHROPIC_API_KEYoffersdk-api, offer-appOptional. Turns on dynamic save offers and the Agent page.
RENDER_API_KEYoffersdk-apiOptional. Runs pauses as Workflow tasks (below).

Deploy on Render walks through the whole deploy, including seeding the demo workspace, and Environment variables lists every variable.

Private networking

The Blueprint keeps traffic between services off the public internet where it can:

  • The API reads DATABASE_URL from the database's internal connection string.
  • The dashboard gets OFFER_API_INTERNAL_URL from the API service's hostport. Its server calls the API at that address, and a bare host:port is treated as http. OFFER_API_URL is still the public address shown in snippets and the API reference.
  • ADMIN_API_KEY is set once on the API and copied to the dashboard (as OFFER_API_ADMIN_KEY) and to the Workflows service with fromService, so the three always share one value.

The Workflows service calls the API at its public OFFER_API_URL, with the admin key.

The API also reads RENDER_EXTERNAL_URL, which Render sets on every web service to its onrender.com address. When an app connects PayPal, the API registers PayPal's webhook there, so billing events reach it with nothing to configure. PUBLIC_API_URL overrides it.

Pauses on Render Workflows

When a customer accepts a pause in a cancel flow, billing is suspended in PayPal and the account moves to the free plan for a number of months. A pause can last longer than a task is allowed to run (24 hours), so no task sleeps. The resume date is stored on the account, and the API checks for pauses that are due.

TaskStarted byWhat it does
pause-subscriptionThe API, when a customer accepts a pause.Calls POST /pauses/apply. Up to 6 retries, 30 seconds apart at first and doubling each time.
resume-due-pausesThe API's timer, when at least one pause is due.Reads GET /pauses/due?limit=100 and starts one resume-subscription run per account, so one account's PayPal error doesn't hold up the rest.
resume-subscriptionresume-due-pausesCalls POST /pauses/resume, which reactivates billing in PayPal and restores the plan. Same retries as pause-subscription.

The tasks hold no billing logic. They call the API's admin-only /pauses routes, and the API talks to PayPal, updates the account and sends subscription.paused or subscription.resumed. A 404 or 409 from the API (already paused, already resumed, account gone) ends the run as skipped. Any other failure throws, and Render retries the task.

  • GET/pauses/due?limit=100
    Accounts whose pause has reached its resume date, across all apps.Admin key
  • POST/pauses/apply
    Body { app_id, account_id, months, session_id?, reason? }. Suspends in PayPal and moves the account to the free plan.Admin key
  • POST/pauses/resume
    Body { app_id, account_id }. Reactivates billing and restores the paused plan.Admin key

How the API starts runs

The API uses Render's SDK (@renderinc/sdk) to start tasks by name. Workflow mode is on when both variables are set on the API:

VariableValue
RENDER_API_KEYA Render API key from your account settings. The Render SDK reads it from the environment.
RENDER_WORKFLOW_SLUGThe Workflows service name. The Blueprint sets it to offersdk-workflows.
api/src/lib/pauses.ts
render ??= new Render();
const run = await render.workflows.startTask(`${process.env.RENDER_WORKFLOW_SLUG}/${name}`, args);
return run.taskRunId;

When a cancel flow's pause is accepted, the session's result includes mode: "workflow" and the task_run_id. A timer in the API checks for due pauses every 60 minutes in workflow mode and starts one resume-due-pauses run when any exist.

Without Render Workflows

If either variable is missing (local development, tests, or a deploy without RENDER_API_KEY), the API applies a pause in the request itself and records mode: "inline". The same timer resumes due pauses in-process every 10 minutes. Nothing else changes for the customer.

VariableEffect
PAUSE_WORKER_INTERVAL_MSOverrides how often the timer checks for due pauses.
PAUSE_WORKERSet to false to leave the timer to other API instances.
Check which mode is active. The cancel flow page in the dashboard shows Pauses run on: Render Workflows or The API. It reads this from GET /apps/:appId/cancel-flows/capabilities.

A pause made directly with POST /apps/:appId/namespaces/:namespaceId/subscription/pause always applies inline. Only pauses accepted in a cancel flow go through the workflow. See PayPal for the PayPal side of pausing.