Built with

Render

Render runs all of resell.store: the Next.js app on every subdomain, Postgres with pgvector, and the Workflows that release sellers' money and refund buyers on time, retrying until it works.

What runs there

Everything is in one Blueprint, render.yaml at the repo root.

NameTypeDoes
resell-storeWeb service (Node 24, starter)The Next.js app: marketplace, stores, seller app, API, MCP and docs
resell-dbPostgres 17 (basic-256mb)All data, with pgvector for listing embeddings
resell-sweepsWorkflowThe timed side of offers and orders, including moving money
resell-sweeps-cronCron Job, every 15 minutesStarts the sweep tasks

Steps to deploy it are on Deploy to Render, and what each sweep does on Background jobs.

Web service

  • Build: corepack enable && pnpm install --frozen-lockfile && pnpm --filter app build. Start: pnpm --filter app start. Health check /.
  • preDeployCommand: pnpm db:migrate runs the Drizzle migrations before each new version takes traffic, so the code and the schema always match.
  • Two custom domains: resell.store and *.resell.store. The wildcard is what lets every shop have its own address (maya.resell.store), and it also covers api., docs. and mcp., which proxy.ts rewrites to /api/v1, /docs and /api/mcp. One service serves them all, and the sign-in cookie is shared on .resell.store.

Postgres

Render's managed Postgres 17 has pgvector available. The first migration (0000_enable-pgvector) switches it on; listings get a vector(1024) column with an HNSW cosine index, which search uses next to Postgres full text. The web service and the workflow both get DATABASE_URL from the database in the Blueprint.

Workflows

apps/app/workflows/main.ts defines tasks with @renderinc/sdk/workflows. They call the same functions as the app (lib/server/sweeps.ts), so there's one copy of every rule. The service starts with pnpm --filter app workflows (tsx, with a stub for Next's server-only).

TaskDoesRetriesTimeout
offerSweepExpires lapsed offers; reminds whoever's turn it is 12 hours before2, from 30 s, doubling10 min
orderSweepShip reminders, arrival checks, escalating quiet problems; fans out cancellations and releases2, from 30 s, doubling30 min
digestSweepFollower digests, the seller's evening agent summary, review requests2, from 30 s, doubling30 min
webhookSweepRetries failed webhook deliveries; clears old API activity2, from 30 s, doubling15 min
releaseOrderCompletes one order 13 days after shipping (or confirmed) and pays the seller6, from 1 min, doubling2 min
cancelUnshippedCancels one order that never shipped and refunds the buyer6, from 1 min, doubling2 min
Fan-out, one task per order
// apps/app/workflows/main.ts
const moneyRetry = { maxRetries: 6, waitDurationMs: 60_000, backoffScaling: 2 };

export const releaseOrderTask = task(
  { name: "releaseOrder", retry: moneyRetry, timeoutSeconds: 120 },
  async function releaseOrder(_ctx: TaskContext, orderId: string) {
    return { orderId, released: await releaseDue(orderId) };   // throws if PayPal won't release yet
  },
);

export const orderSweep = task({ name: "orderSweep", retry: sweepRetry, timeoutSeconds: 1800 },
  async function orderSweep(ctx: TaskContext) {
    // ship reminders, arrival checks, escalations…
    const [cancel, release] = await Promise.all([dueCancellations(), dueReleases()]);
    await Promise.allSettled([
      ...cancel.map((id) => ctx.run(cancelOrderTask, id)),
      ...release.map((id) => ctx.run(releaseOrderTask, id)),
    ]);
  });

Why durable tasks for money

  • One order can't hold up the rest. Each release and each cancellation is its own task run. If PayPal refuses one, that order alone retries, while the others are already paid.
  • Failures retry on their own. releaseDue throws when PayPal won't release yet, so Render tries again with backoff, up to six more times over about an hour, without anyone watching. Anything still unpaid is picked up by the next sweep.
  • Retrying is safe. Every money call carries a PayPal-Request-Id, and every step checks the order's status first, so a retry or a doubled run never pays or refunds twice.
  • Promises are kept on time. Buyers are told they'll be refunded if it doesn't ship in 7 days, and sellers that they're paid 13 days after shipping if the buyer doesn't confirm sooner. Those are timers that have to fire, and must happen before PayPal's 28-day hold runs out.
  • It's visible. Each run, its retries and its result show in the Render dashboard.

Cron starts them

Workflows don't schedule themselves yet, so resell-sweeps-cron runs pnpm --filter app sweeps:start every 15 minutes. scripts/start-sweeps.ts calls render.workflows.startTask("resell-sweeps/offerSweep", …) for the four sweeps, with an idempotency key per quarter hour so a doubled cron run doesn't start a second sweep. It needs RENDER_API_KEY and RENDER_WORKFLOW_SLUG (filled in from the workflow by the Blueprint).

Running it locally

Terminal
# the real thing, with the Render CLI, from apps/app
render workflows dev -- pnpm workflows

# or one pass of every sweep, in-process
curl -X POST localhost:5689/api/cron/sweep
POST /api/cron/sweep runs runSweeps() in the web process. Outside development it needs Authorization: Bearer $CRON_SECRET. It's also the fallback for a host without Workflows.

What it unlocks

  • Every shop on its own subdomain, all from one web service.
  • Search by meaning, with vectors in the same database as everything else.
  • Sellers paid on time and buyers refunded on time, with retries, without a person or a queue to look after.
  • Offers that expire to the second, reminders that go once, and webhooks that get a second chance.