Built with
Render
What runs there
Everything is in one Blueprint, render.yaml at the repo root.
| Name | Type | Does |
|---|---|---|
resell-store | Web service (Node 24, starter) | The Next.js app: marketplace, stores, seller app, API, MCP and docs |
resell-db | Postgres 17 (basic-256mb) | All data, with pgvector for listing embeddings |
resell-sweeps | Workflow | The timed side of offers and orders, including moving money |
resell-sweeps-cron | Cron Job, every 15 minutes | Starts 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:migrateruns the Drizzle migrations before each new version takes traffic, so the code and the schema always match.- Two custom domains:
resell.storeand*.resell.store. The wildcard is what lets every shop have its own address (maya.resell.store), and it also coversapi.,docs.andmcp., whichproxy.tsrewrites to/api/v1,/docsand/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).
| Task | Does | Retries | Timeout |
|---|---|---|---|
offerSweep | Expires lapsed offers; reminds whoever's turn it is 12 hours before | 2, from 30 s, doubling | 10 min |
orderSweep | Ship reminders, arrival checks, escalating quiet problems; fans out cancellations and releases | 2, from 30 s, doubling | 30 min |
digestSweep | Follower digests, the seller's evening agent summary, review requests | 2, from 30 s, doubling | 30 min |
webhookSweep | Retries failed webhook deliveries; clears old API activity | 2, from 30 s, doubling | 15 min |
releaseOrder | Completes one order 13 days after shipping (or confirmed) and pays the seller | 6, from 1 min, doubling | 2 min |
cancelUnshipped | Cancels one order that never shipped and refunds the buyer | 6, from 1 min, doubling | 2 min |
// 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.
releaseDuethrows 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
# 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/sweepPOST /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.