Built with
PayPal
What it does here
resell.store is a PayPal multiparty platform (PayPal Complete Payments, PPCP). Sellers connect their own PayPal account as merchants. Each order is made out to the seller, carries resell.store's fee, and uses delayed disbursement so the money waits at PayPal until we release it. The client is lib/server/paypal.ts; the flow end to end is on Payments and payouts.
Every endpoint
| Endpoint | Function | Why |
|---|---|---|
POST /v1/oauth2/token | accessToken() | Client credentials. Cached and refreshed a minute before it expires. |
POST /v2/customer/partner-referrals | createSellerSignupLink() | A one-time sign-up link for a seller, asking for the four permissions a sale needs. |
GET /v1/customer/partners/{partner}/merchant-integrations?tracking_id= | sellerByTrackingId() | Who signed up under our user id. |
GET /v1/customer/partners/{partner}/merchant-integrations/{merchant} | sellerByMerchantId() | Can they take payments, is their email confirmed, what did they grant. |
POST /v2/checkout/orders | createOrder() | The order: paid to the seller, our fee in it, disbursement DELAYED. |
POST /v2/checkout/orders/{id}/capture | captureOrder() | Takes the approved payment. Returns PayPal's fee, our fee and the seller's net. |
POST /v1/payments/referenced-payouts-items | releaseToSeller() | Releases a held capture to the seller (reference_type TRANSACTION_ID). |
POST /v2/payments/captures/{id}/refund | refundCapture() | All or part back to the buyer, acting as the seller. |
POST /v1/notifications/verify-webhook-signature | verifyWebhook() | Asks PayPal whether a webhook delivery is genuine. |
Headers
| Header | When | Why |
|---|---|---|
PayPal-Partner-Attribution-Id | Every call, when PAYPAL_BN_CODE is set | Attributes the call to the platform. |
PayPal-Request-Id | Orders, captures, releases, refunds | Idempotency: the same id never charges, pays or refunds twice. |
PayPal-Auth-Assertion | Refunds | An unsigned JWT, {iss: clientId, payer_id: merchantId}, to act for the seller whose money it is. |
Prefer: return=representation | Every call | Full objects back, so fees come straight from the response. |
Seller onboarding
lib/server/paypal-sellers.ts, on Connections (/tools/connections). The sign-up link asks for PAYMENT, REFUND, PARTNER_FEE and DELAY_FUNDS_DISBURSEMENT with a third-party REST integration, and returns to /api/paypal/connect/return. PayPal knows the seller by our user id (the tracking id), so what we save always comes from PayPal, never from the return URL. PayPal doesn't always send people back, so Connections also checks on its own while a connect is in progress.
| readiness() | Means |
|---|---|
none | Not connected |
confirm-email | They need to confirm their PayPal email |
not-receivable | PayPal won't let the account take payments yet |
missing-permissions | They didn't grant partnerfee or delay-funds-disbursement |
ready | Good to sell |
Holding and releasing
- The order sets
payee.merchant_idto the seller,payment_instruction.platform_feesto our fee (PLATFORM_FEE_BPS, 10% of the item price by default, never on shipping), anddisbursement_mode: DELAYED. - Checkout uses
user_action: PAY_NOWandshipping_preference: NO_SHIPPING(we already have the address). Paying by card setslanding_page: GUEST_CHECKOUT, so a buyer without PayPal gets the card form first. - The capture happens inside the database transaction that writes the order, with the listing row locked.
- Release is a referenced payout of the capture. It happens when the buyer says it arrived, or when the order sweep reaches 13 days after shipping (10 assumed in transit, 3 to check; there's no carrier tracking), and always within 27 days of payment, before PayPal's own 28-day limit. A live problem blocks it.
Refunds
giveBack in lib/server/disputes.ts refunds the capture as the seller: the whole order for a cancellation, part of it for an agreed partial refund, or the rest of it for a problem. note_to_payer tells the buyer why. Once the money has been released it can't be refunded here.
Webhook events
POST /api/paypal/webhook, handled in lib/server/paypal-webhook.ts. Verified with PayPal first (401 if not), stored by event id in paypal_event so redeliveries are no-ops, 500 on failure so PayPal retries.
| Event | What we do |
|---|---|
PAYMENT.CAPTURE.COMPLETED | Recorded; nothing to change. |
PAYMENT.CAPTURE.DENIED / DECLINED | A pending capture that failed: the order is cancelled and the listing goes back on sale. |
PAYMENT.CAPTURE.REFUNDED | Records ours or one made in PayPal. Full: refunded (or cancelled if it never shipped). |
PAYMENT.CAPTURE.REVERSED | A chargeback: marked fully refunded, any open problem settled. |
CUSTOMER.DISPUTE.CREATED / UPDATED / RESOLVED | Mirrored onto the order's problem, following PayPal's outcome. |
MERCHANT.ONBOARDING.COMPLETED | Syncs the seller's account. |
MERCHANT.PARTNER-CONSENT.REVOKED | Marks the seller unable to take payments. |
PAYMENT.REFERENCED-PAYOUT-ITEM.COMPLETED | Records the release. |
PAYMENT.REFERENCED-PAYOUT-ITEM.FAILED | Clears the release and counts the failure (failed:n), so the sweep tries again with a fresh request id. |
Sandbox demo accounts
So anyone can try a sale without making PayPal accounts, the sandbox has shared test logins. Checkout shows the demo buyer login, and Connections shows the demo seller login and offers to link a seller to the shared demo seller (linkDemoPayPal). Seeded shops have no PayPal, so their money goes to the demo seller too. Connect it once:
cd apps/app
node --env-file=.env.local scripts/paypal-demo-seller.mjs
# first run: prints a PayPal link to open as the demo seller
# second run: prints the merchant id for PAYPAL_DEMO_SELLER_IDdemoSellerId() and sandboxLogin() return null when PAYPAL_ENV=live.
What it unlocks
- Buyers pay only for what arrives. The money is held until they say it's all good, or 13 days after shipping pass without a problem.
- Sellers get paid automatically, straight to their own PayPal, without chasing anyone.
- The platform earns its fee without touching the money. PayPal splits it at capture.
- No account needed to buy. Guest card checkout.
- Refunds and disputes work in full or in part, and PayPal disputes show up in the app's problem flow.
Without it
With no client id, secret or partner id, paypalEnabled() is false and checkout is a test checkout: orders are real, the mock provider takes no money, refunds are recorded on paper, releases are skipped, Connections says "PayPal isn't set up on this server yet", and the webhook answers 404.
Setup
PAYPAL_ENV=sandbox # "live" switches the base URL; never set for this project
PAYPAL_CLIENT_ID=… # the platform app (Developer Dashboard > Apps & Credentials)
PAYPAL_CLIENT_SECRET=…
PAYPAL_PARTNER_MERCHANT_ID=… # the platform's own merchant id
PAYPAL_BN_CODE=… # partner attribution, sent on every call
PAYPAL_WEBHOOK_ID=… # the webhook pointed at /api/paypal/webhook
NEXT_PUBLIC_PAYPAL_CLIENT_ID=… # only changes the payment note shown in the app
PLATFORM_FEE_BPS=1000 # 10% of the item price
# Sandbox demo
PAYPAL_DEMO_SELLER_ID=… # printed by scripts/paypal-demo-seller.mjs
PAYPAL_DEMO_SELLER_EMAIL=… PAYPAL_DEMO_SELLER_PASSWORD=…
PAYPAL_DEMO_BUYER_EMAIL=… PAYPAL_DEMO_BUYER_PASSWORD=…
PAYOUT_DEMO_MINUTES_PER_DAY= # e.g. 1: a "day" lasts a minuteSubscribe the webhook to the events in the table above. On Render, the workflow service needs the same PayPal keys (minus the webhook id and demo logins) because it releases and refunds too. See Environment variables.
Going live
Sandbox only
api-m.sandbox.paypal.com and no real money moves. The code switches base URL on PAYPAL_ENV=live and drops the demo seller, but it has never been run that way.