Developers

Background jobs

Offers run out, sellers get nudged, unshipped orders are refunded and held money is released, all without anyone clicking. Here's what runs, when, and how it's started.

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)

JobWhenWhat happens
expireOffersexpires_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
remindOffers12 hours before expiryThe seller if it's theirs to answer, otherwise the buyer

Orders (orderSweep)

JobWhenWhat happens
remindShipping3 days after payment, still not shipped"Did you ship it?" to the seller; the buyer is told they can now cancel
cancelUnshipped7 days after payment, still not shippedOrder cancelled, the buyer refunded in full, both told
checkArrivals7 days after shipping"Did it arrive?" to the buyer, unless there's an open problem
releaseDue13 days after shipping, if the buyer hasn't confirmed it arrivedOrder 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
escalateQuiet3 days after a buyer reports a problem the seller hasn't answeredEscalated 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)

JobWhenWhat happens
sendFollowDigestsWhenever 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
sendAgentSummariesOnce 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
requestReviews2 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:

scripts/start-sweeps.ts
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.

TaskRetriesTimeout
offerSweep2, from 30 s, doubling10 min
orderSweep2, from 30 s, doubling30 min
digestSweep2, from 30 s, doubling30 min
webhookSweep2, from 30 s, doubling15 min
releaseOrder6, from 60 s, doubling2 min
cancelUnshipped6, from 60 s, doubling2 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 the notice table with on conflict do nothing. If no row comes back, someone already sent it. Kinds include offer.open-reminder, order.ship-reminder, order.arrival-check, order.paid-out and agent-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_at ten 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:

Terminal
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.

For a live demo: set it to 1, mark a sale shipped, then run /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):

  1. Research. Starting a listing creates a research_run and runs runResearch() after the response. Each step writes its progress to the row, and the research page polls /api/listings/[id]/research every 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.
  2. The store agent. When a buyer sends a message, store-agent.ts answers it after the response (if the shop allows it and there's an Anthropic key), or hands the thread to the seller.
  3. The negotiator. A new offer triggers negotiator.ts, which may counter within the seller's lowest price.
  4. 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.