Developers
Background jobs
What's timed
All of it is in apps/app/lib/server/sweeps.ts, with the money timings in payout-policy.ts. Each sweep looks for what's due right now and acts on it, so the timings below are "at the first sweep after".
Offers (offerSweep)
| Job | When | What happens |
|---|---|---|
expireOffers | expires_at has passed (48 hours after the last move) | Open, countered or accepted offers become expired; an offer.updated webhook and emails to both sides |
remindOffers | 12 hours before expiry | The seller if it's theirs to answer, otherwise the buyer |
Orders (orderSweep)
| Job | When | What happens |
|---|---|---|
remindShipping | 3 days after payment, still not shipped | "Did you ship it?" to the seller; the buyer is told they can now cancel |
cancelUnshipped | 7 days after payment, still not shipped | Order cancelled, the buyer refunded in full, both told |
checkArrivals | 7 days after shipping | "Did it arrive?" to the buyer, unless there's an open problem |
releaseDue | 13 days after shipping, if the buyer hasn't confirmed it arrived | Order completed and the held money released to the seller through PayPal, unless there's an open problem. There's no carrier tracking, so delivery is assumed 10 days after shipping, then the buyer gets a 3-day check window. Never later than 27 days after payment, a day inside PayPal's own 28-day release |
escalateQuiet | 3 days after a buyer reports a problem the seller hasn't answered | Escalated to resell.store, with a note that the seller didn't answer in time |
Most orders never wait for this: when the buyer taps It's all good, the order completes and the money is released right away. releaseDue also retries completed PayPal orders whose release failed earlier (released_at still empty).
Roll-ups (digestSweep)
| Job | When | What happens |
|---|---|---|
sendFollowDigests | Whenever a followed shop has published something since the last one | "New from shops you follow", one email per shop. Off by default; people turn it on in their settings |
sendAgentSummaries | Once a day, from AGENT_SUMMARY_HOUR UTC (default 1, early evening in the US) | The seller's summary of what their shop's agent did and what's waiting on them. On by default |
requestReviews | 2 days after an order completes (up to 30 days) | "How was it?" to the buyer, once, if they haven't reviewed |
Webhooks (webhookSweep)
Failed deliveries to a seller's webhook are tried again after 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then given up. Since the sweep runs every 15 minutes, the short waits round up to the next sweep. An endpoint that has been failing for 3 days is turned off. Deliveries are kept 30 days and API activity 90.
The workflow and the cron
In production the sweeps run as tasks on the resell-sweeps Render Workflow (apps/app/workflows/main.ts). Workflows can't schedule themselves, so the resell-sweeps-cron Render Cron Job runs scripts/start-sweeps.ts every 15 minutes, which starts four tasks and exits:
const slot = Math.floor(Date.now() / (15 * 60 * 1000));
for (const name of ["offerSweep", "orderSweep", "digestSweep", "webhookSweep"]) {
await render.workflows.startTask(`${slug}/${name}`, [], { idempotencyKey: `${name}-${slot}` });
}The idempotency key is the task name plus the quarter hour, so if the cron fires twice in the same slot Render starts each sweep only once.
| Task | Retries | Timeout |
|---|---|---|
offerSweep | 2, from 30 s, doubling | 10 min |
orderSweep | 2, from 30 s, doubling | 30 min |
digestSweep | 2, from 30 s, doubling | 30 min |
webhookSweep | 2, from 30 s, doubling | 15 min |
releaseOrder | 6, from 60 s, doubling | 2 min |
cancelUnshipped | 6, from 60 s, doubling | 2 min |
orderSweep doesn't move money itself. It finds the orders due for cancelling or releasing and starts one cancelUnshipped or releaseOrder task per order with ctx.run. If PayPal refuses a release, that task throws and Render retries just that order, with backoff, while the others carry on.
Never twice
- Reminders are claimed first. Before sending one, a sweep inserts
(kind, ref_id)into thenoticetable withon conflict do nothing. If no row comes back, someone already sent it. Kinds includeoffer.open-reminder,order.ship-reminder,order.arrival-check,order.paid-outandagent-summary:YYYY-MM-DD. - State changes re-check the row. Expiring an offer updates it only if its status hasn't moved; completing an order only if it's still shipped. A sweep that runs twice, or two at once, does nothing extra.
- Webhook retries are claimed by pushing
next_attempt_atten minutes ahead before sending.
Without Render
POST /api/cron/sweep runs one pass of every sweep in the web process (runSweeps()) and returns what it did. Outside development it needs the CRON_SECRET:
curl -X POST https://resell.store/api/cron/sweep \
-H "Authorization: Bearer $CRON_SECRET"Point any scheduler at it (every 15 minutes matches production). Locally no secret is needed: curl -X POST localhost:5689/api/cron/sweep. In this mode money moves aren't retried with backoff; a failed release is logged and picked up by the next pass.
Shrinking the days
PAYOUT_DEMO_MINUTES_PER_DAY turns every "day" in payout-policy.ts into that many minutes. With 2, the money for a shipped order is released 26 minutes after shipping (10 "days" plus the 3-day window). It doesn't change the 48-hour offer window, the 12-hour reminder or the digest timings. Set it on the workflow as well as the web service, and leave it unset in production.
/api/cron/sweep (or wait for the cron) and watch the payout land in the sandbox seller's PayPal.Work after the response
Some work is started by a person but shouldn't make them wait. It runs in the web process after the response is sent, through Next's after() or runAfterResponse() in lib/server/later.ts (which just runs it in the background when there's no request, as in scripts):
- Research. Starting a listing creates a
research_runand runsrunResearch()after the response. Each step writes its progress to the row, and the research page polls/api/listings/[id]/researchevery 1.2 seconds. A run that hasn't updated in 3 minutes counts as stuck, and a new one can start. The Shopping sidekick's checks work the same way. - The store agent. When a buyer sends a message,
store-agent.tsanswers it after the response (if the shop allows it and there's an Anthropic key), or hands the thread to the seller. - The negotiator. A new offer triggers
negotiator.ts, which may counter within the seller's lowest price. - Emails for offers, sales, shipping and problems, and the API's activity log.
None of these are retried if the process restarts mid-way. More on the agents in Research and agents.