Developers
Payments and payouts
The model
resell.store is a PayPal multiparty platform. Each seller connects their own PayPal account. When a buyer pays, the order is made out to the seller (payee.merchant_id), with resell.store's fee written into it (platform_fees) and the seller's share held by PayPal (disbursement_mode: DELAYED). resell.store never holds the money itself; it only tells PayPal when to let it go.
| File | What it does |
|---|---|
lib/server/paypal.ts | The REST client: token, onboarding, orders, capture, release, refund, webhook check. |
lib/server/paypal-sellers.ts | Connecting a seller, syncing their account, readiness. |
lib/server/commerce.ts | Checkout, placing the order, offers, shipping, releasing. |
lib/server/disputes.ts | Cancelling, problems and refunds. |
lib/server/payout-policy.ts | The fee and every timer. |
lib/server/paypal-webhook.ts | What PayPal tells us after the fact. |
lib/server/sweeps.ts, workflows/main.ts | The timed side: cancellations and releases, retried. |
A sale, step by step
- Connect the seller. On Connections (
/tools/connections) the seller gets a one-time PayPal link from Partner Referrals (POST /v2/customer/partner-referrals), asking forPAYMENT,REFUND,PARTNER_FEEandDELAY_FUNDS_DISBURSEMENT. The tracking id is our user id. Back on resell.store,syncPayPalAccountasks PayPal who signed up under that tracking id, never trusting the return URL's query string, and savespaypal_account.readiness()is "ready" when their email is confirmed, payments are receivable, and they grantedpartnerfeeanddelay-funds-disbursement. - Start checkout.
startCheckoutprices it (the listing, or an accepted offer, plus shipping), finds the payee, works out the fee and creates the PayPal order. Acheckoutrow remembers what the buyer chose and the exact amounts. The buyer goes to PayPal's page: the login page for PayPal, the guest card form for a card. Nothing is charged yet. - Buyer approves. PayPal sends them to
/api/paypal/checkout/return?token={orderId}. - Capture inside the order.
finishCheckoutcallsplaceOrder, which locks the listing row, prices it again, and refuses if anything moved since they left ("The price changed while you were in PayPal"). Only then does it capture, inside the same transaction, and write the order with PayPal's fee and the seller's net from the capture'sseller_receivable_breakdown. The listing is marked sold. If the capture took money but the order can't be written, the money is refunded straight away. - Hold. The order is
paid. The seller ships within 3 days and marks it shipped. The money stays with PayPal. - Release. When the buyer taps "It's all good" (
confirmReceived), or when the order sweep finds 13 days have passed since shipping (releaseDuein a workflow task), the order completes andreleaseOrderasks PayPal to pay the held capture out with a referenced payout (POST /v1/payments/referenced-payouts-items). Never while a problem is open. - Or refund. Before release, refunds go back through PayPal as the seller (
giveBackindisputes.ts), in full or in part.
POST /v2/checkout/orders
PayPal-Request-Id: order-{checkoutId}
PayPal-Partner-Attribution-Id: {PAYPAL_BN_CODE}
{
"intent": "CAPTURE",
"purchase_units": [{
"reference_id": "{checkoutId}",
"amount": { "currency_code": "USD", "value": "109.00",
"breakdown": { "item_total": { "value": "100.00" }, "shipping": { "value": "9.00" } } },
"items": [{ "name": "Yellow dutch oven, 5.5 qt", "quantity": "1", "category": "PHYSICAL_GOODS", … }],
"payee": { "merchant_id": "{seller's PayPal merchant id}" },
"payment_instruction": {
"disbursement_mode": "DELAYED",
"platform_fees": [{ "amount": { "currency_code": "USD", "value": "10.00" } }]
}
}],
"payment_source": { "paypal": { "experience_context": {
"user_action": "PAY_NOW", "shipping_preference": "NO_SHIPPING",
"landing_page": "GUEST_CHECKOUT", "return_url": "…/api/paypal/checkout/return", … } } }
}A worked example
Jess buys a $100 item with $9 tracked shipping. These are the numbers from a sandbox capture; PayPal's own fee is whatever PayPal reports in the capture, so treat it as an example.
| Amount | Where it comes from | |
|---|---|---|
| Jess pays | $109.00 | Item plus shipping |
| resell.store's fee | $10.00 | 10% of the item price (PLATFORM_FEE_BPS, default 1000). Shipping is never charged a fee. |
| PayPal's fee | $4.29 | From the seller's share, as PayPal reports it on capture |
| Seller gets | $94.71 | Held until release, then paid to their PayPal |
One item, one buyer
Every listing is a single item. placeOrder selects the listing FOR UPDATE, so two buyers paying at the same moment queue: the second finds it sold ("Sorry, someone just bought this one") and is never captured. In the same transaction the buyer's offer is marked paid and every other open, countered or accepted offer on it is declined. Paused shops and your own listings can't be bought.
Safe to retry
Every PayPal call that moves money carries a PayPal-Request-Id. The same id gets the same result from PayPal, so a retried task, a refreshed return page or a doubled click never charges, pays or refunds twice.
| Call | Request id |
|---|---|
| Create order | order-{checkoutId} |
| Capture | capture-{checkoutId} |
| Release | release-{captureId}, then release-{captureId}-{n} after PayPal reports a failure |
| Cancel refund | cancel-{orderId} |
| Part refund | partial-{disputeId} |
| Full refund for a problem | refund-{disputeId} |
finishCheckout is safe to run twice too: a completed checkout returns the same order.
Timings
From lib/server/payout-policy.ts. PAYOUT_DEMO_MINUTES_PER_DAY shrinks a "day" to that many minutes so the auto-release can be shown live; leave it unset in production.
No carrier tracking yet
delivered status and a deliveredAt date, but nothing sets them from a carrier: there's no tracking service wired in. So an order completes in one of two ways. The buyer confirms it arrived, or the order sweep assumes delivery 10 days after shipping and releases 3 days after that, never later than 27 days after payment.| Rule | Value |
|---|---|
| Ship within (then the buyer may cancel) | 3 days after payment |
| Not shipped: cancelled and refunded | 7 days after payment |
| "Did it arrive?" check | 7 days after shipping |
| Assumed delivered | 10 days after shipping |
| Buyer's check window after that | 3 days, so released 13 days after shipping |
| Latest release | 27 days after payment: PayPal's 28-day hold, less a day for the sweep |
| Seller silent on a problem: resell.store steps in | 3 days |
| Offers, and accepted offers waiting for payment | 48 hours |
Refunds and problems
- The seller can cancel any time before shipping; the buyer once the ship-by date passes; the sweep after 7 days. Everything goes back.
- After shipping, the buyer reports a problem. The money stays held while it's open or escalated.
- The seller can refund in full, offer part back, or reply. Either side can ask resell.store to step in, and
resolveDisputedecides: refund, or release. - Refunds use
PayPal-Auth-Assertion, an unsigned JWT naming the platform and the seller, because the money is the seller's. Once released, it can't be refunded here. - Disputes opened in PayPal come in through the webhook and follow PayPal's outcome; PayPal moves that money itself.
The PayPal webhook
POST /api/paypal/webhook checks each delivery with PayPal (verify-webhook-signature, with PAYPAL_WEBHOOK_ID) before believing it, and answers 401 if it doesn't check out. Each event is stored by PayPal's id in paypal_event first, so a redelivery is a no-op, and each handler only moves an order forward. A failure answers 500 so PayPal tries again. The events handled are listed on PayPal.
The sandbox demo seller
Seeded shops have no PayPal of their own. In the sandbox, payeeFor falls back to one shared demo seller (PAYPAL_DEMO_SELLER_ID) when the shop owner's account can't take payments, and Connections offers to link a test account to it instead of signing up. Connect it once with scripts/paypal-demo-seller.mjs, which prints the merchant id. demoSellerId() returns null when PAYPAL_ENV=live, so this never happens with real money.
Without PayPal keys
paypalEnabled() needs PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET and PAYPAL_PARTNER_MERCHANT_ID. Without them checkout is a test checkout: placeOrder runs with a mock payment, the order, lock and emails are all real, and nothing moves. Refunds on mock orders are recorded on paper, releases are skipped, Connections says PayPal isn't set up, and the webhook answers 404.
Sandbox only
api-m.sandbox.paypal.com. Going live needs PayPal's partner approval first.