Built with

PayPal

PayPal is how money moves on resell.store: buyers pay sellers directly, PayPal holds the seller's share until the item arrives, and resell.store's fee is taken out on the way. The project runs on PayPal's sandbox.

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

EndpointFunctionWhy
POST /v1/oauth2/tokenaccessToken()Client credentials. Cached and refreshed a minute before it expires.
POST /v2/customer/partner-referralscreateSellerSignupLink()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/orderscreateOrder()The order: paid to the seller, our fee in it, disbursement DELAYED.
POST /v2/checkout/orders/{id}/capturecaptureOrder()Takes the approved payment. Returns PayPal's fee, our fee and the seller's net.
POST /v1/payments/referenced-payouts-itemsreleaseToSeller()Releases a held capture to the seller (reference_type TRANSACTION_ID).
POST /v2/payments/captures/{id}/refundrefundCapture()All or part back to the buyer, acting as the seller.
POST /v1/notifications/verify-webhook-signatureverifyWebhook()Asks PayPal whether a webhook delivery is genuine.

Headers

HeaderWhenWhy
PayPal-Partner-Attribution-IdEvery call, when PAYPAL_BN_CODE is setAttributes the call to the platform.
PayPal-Request-IdOrders, captures, releases, refundsIdempotency: the same id never charges, pays or refunds twice.
PayPal-Auth-AssertionRefundsAn unsigned JWT, {iss: clientId, payer_id: merchantId}, to act for the seller whose money it is.
Prefer: return=representationEvery callFull 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
noneNot connected
confirm-emailThey need to confirm their PayPal email
not-receivablePayPal won't let the account take payments yet
missing-permissionsThey didn't grant partnerfee or delay-funds-disbursement
readyGood to sell

Holding and releasing

  • The order sets payee.merchant_id to the seller, payment_instruction.platform_fees to our fee (PLATFORM_FEE_BPS, 10% of the item price by default, never on shipping), and disbursement_mode: DELAYED.
  • Checkout uses user_action: PAY_NOW and shipping_preference: NO_SHIPPING (we already have the address). Paying by card sets landing_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.

EventWhat we do
PAYMENT.CAPTURE.COMPLETEDRecorded; nothing to change.
PAYMENT.CAPTURE.DENIED / DECLINEDA pending capture that failed: the order is cancelled and the listing goes back on sale.
PAYMENT.CAPTURE.REFUNDEDRecords ours or one made in PayPal. Full: refunded (or cancelled if it never shipped).
PAYMENT.CAPTURE.REVERSEDA chargeback: marked fully refunded, any open problem settled.
CUSTOMER.DISPUTE.CREATED / UPDATED / RESOLVEDMirrored onto the order's problem, following PayPal's outcome.
MERCHANT.ONBOARDING.COMPLETEDSyncs the seller's account.
MERCHANT.PARTNER-CONSENT.REVOKEDMarks the seller unable to take payments.
PAYMENT.REFERENCED-PAYOUT-ITEM.COMPLETEDRecords the release.
PAYMENT.REFERENCED-PAYOUT-ITEM.FAILEDClears 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:

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

demoSellerId() 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

apps/app/.env.local
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 minute

Subscribe 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

A live multiparty integration needs PayPal to approve the platform as a partner, with its own BN code and live credentials. resell.store hasn't been through that, so it only runs against 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.