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.
| Resource | Type | What it runs |
|---|---|---|
| offersdk-api | Web service (Docker) | The Offer API from api/Dockerfile. Health check on /health, which also checks the database. |
| offer-app | Web service (Node) | The dashboard. Built with pnpm through corepack, health check on /api/health. |
| offersdk-workflows | Workflow (Node) | The pause tasks in workflows/src/index.ts. Needs Node 24 or newer. |
| offersdk-db | Postgres 17 | All 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:
| Variable | Service | Value |
|---|---|---|
| ADMIN_API_KEY | offersdk-api | A long random secret, for example from openssl rand -hex 32. |
| APP_URL | offersdk-api | The dashboard's public URL. |
| OFFER_API_URL | offer-app, offersdk-workflows | The API's public URL. |
| ANTHROPIC_API_KEY | offersdk-api, offer-app | Optional. Turns on dynamic save offers and the Agent page. |
| RENDER_API_KEY | offersdk-api | Optional. 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_URLfrom the database's internal connection string. - The dashboard gets
OFFER_API_INTERNAL_URLfrom the API service'shostport. Its server calls the API at that address, and a barehost:portis treated ashttp.OFFER_API_URLis still the public address shown in snippets and the API reference. ADMIN_API_KEYis set once on the API and copied to the dashboard (asOFFER_API_ADMIN_KEY) and to the Workflows service withfromService, 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.
| Task | Started by | What it does |
|---|---|---|
| pause-subscription | The 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-pauses | The 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-subscription | resume-due-pauses | Calls 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=100Accounts whose pause has reached its resume date, across all apps. - POST
/pauses/applyBody{ app_id, account_id, months, session_id?, reason? }. Suspends in PayPal and moves the account to the free plan. - POST
/pauses/resumeBody{ app_id, account_id }. Reactivates billing and restores the paused plan.
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:
| Variable | Value |
|---|---|
| RENDER_API_KEY | A Render API key from your account settings. The Render SDK reads it from the environment. |
| RENDER_WORKFLOW_SLUG | The Workflows service name. The Blueprint sets it to offersdk-workflows. |
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.
| Variable | Effect |
|---|---|
| PAUSE_WORKER_INTERVAL_MS | Overrides how often the timer checks for due pauses. |
| PAUSE_WORKER | Set to false to leave the timer to other API instances. |
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.