@volter/twin-paypal
v0.1.37
Published
Local PayPal twin - batch payouts with a deterministic offline lifecycle, really signed webhook notifications, the OAuth 2.0 token endpoint, the Identity userinfo read and the Log in with PayPal consent screen. Built on @volter/twin.
Downloads
2,455
Readme
@volter/twin-paypal
A local, stateful, vendor-faithful replica of the slice of PayPal's REST API an application that pays people talks to. Point an unmodified client at it and it answers the way PayPal does.
PayPal is one product spread over two host families, and this pack serves both because the protocol needs both:
| | what it serves |
|---|---|
| api(-m).(sandbox.)paypal.com | POST /v1/oauth2/token, GET /v1/identity/openidconnect/userinfo, the Batch Payouts API, and the Webhooks Management API including verify-webhook-signature and the signing certificate |
| www.(sandbox.)paypal.com | the Log in with PayPal consent screen at /signin/authorize (and PayPal's documented /connect entry into it) |
world-paypal serve --port 4310Three things are REAL rather than stubbed, because a consumer can tell the difference:
- The webhook signature. Every delivery carries PayPal's five verification headers and a
genuine RSASSA-PKCS1-v1_5 / SHA-256 signature over the vendor's own message,
transmissionId|time|webhookId|crc32(rawBody).GET /v1/notifications/certs/{id}serves a real X.509 certificate thatnew crypto.X509Certificate(pem)parses andcrypto.createVerify('SHA256').verify(certPem, sig)accepts. A receiver verifying offline with unmodified code verifies a twin delivery unchanged — including the misconfiguration case, where the wrongPAYPAL_WEBHOOK_IDcorrectly fails. - The payout lifecycle.
PENDING → PROCESSING → SUCCESS, with the per-item transaction statuses PayPal documents (SUCCESS,FAILED,UNCLAIMED,RETURNED,ONHOLD,BLOCKED,REFUNDED,REVERSED), fees appearing atPROCESSING,time_completed/time_closedatSUCCESS, and the matchingPAYMENT.PAYOUTS-ITEM.*/PAYMENT.PAYOUTSBATCH.*notifications raised as it moves. - The refusals. The duplicate
sender_batch_idPayPal rejects with a HATEOAS link to the original payout; the unregistered return URL the consent screen refuses to bounce to; the settled item that will not transition again; the client-credentials tokenuserinfodeclines because it names an app rather than a person.
No real money ever moves, and nothing here is on a timer. The lifecycle is driven through
twin-only doors (POST /_twin/payouts/{id}/advance, POST /_twin/payouts-item/{id}/transition,
POST /_twin/payouts/{id}/deny) so a served response stays a pure function of the request and
stored state. Those doors are scaffolding for acts PayPal has no API for — it has no endpoint that
creates a REST app, signs a browser in, or steps its own settlement clock — and they are
deliberately not counted as coverage.
Quick tour
# 1. a client-credentials token, the way an integration mints one
curl -su "$PAYPAL_CLIENT_ID:$PAYPAL_CLIENT_SECRET" \
-d grant_type=client_credentials http://localhost:4310/v1/oauth2/token
# 2. a batch payout
curl -X POST http://localhost:4310/v1/payments/payouts \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"sender_batch_header":{"sender_batch_id":"inv_1"},
"items":[{"recipient_type":"EMAIL","receiver":"[email protected]",
"sender_item_id":"po_1","amount":{"value":"12.34","currency":"USD"}}]}'
# 3. subscribe, then drive the lifecycle and watch the signed notifications arrive
curl -X POST http://localhost:4310/v1/notifications/webhooks \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"url":"http://localhost:3000/api/paypal/webhook","event_types":[{"name":"*"}]}'
curl -X POST http://localhost:4310/_twin/payouts/$BATCH/advance -H "authorization: Bearer $TOKEN"
curl -X POST http://localhost:4310/_twin/payouts/$BATCH/advance -H "authorization: Bearer $TOKEN"Every seeded world already holds a REST app (PAYPAL_CLIENT_ID / PAYPAL_CLIENT_SECRET), two
PayPal personas, a browser session signed in as the first of them, and one standing access
token so a caller has something to put in an Authorization header before it has run a token
exchange. Every other bearer is still refused with PayPal's 401.
Coverage
66 done / 220 — 30%. The denominator in src/paypal-capabilities.ts is PayPal's real REST
surface, not a list of what this pack built: nineteen areas (seventeen of PayPal's own product
areas plus this pack's connector and errors), and inside every unserved area one row per
operation the vendor's own OpenAPI document declares — Invoices 22, Subscriptions 16, Disputes
15, Orders 9, Payments 8, Payment Method Tokens 6, Web Experience Profiles 6, Shipment Tracking 5,
Catalog Products 4, Partner Referrals 2, Transaction Search 2. Enumerated, not sampled: a
denominator that carried five of Disputes' fifteen operations would flatter the percentage by
hiding ten.
The density is the honest headline:
| | done / rows | |---|---| | the areas this twin serves (oauth, identity, authorize, payouts, webhooks, errors, connector) | 66 / 122 | | the areas it does not (orders, payments, subscriptions, catalog, invoicing, disputes, reporting, vault, partner referrals, tracking, web profiles, referenced payouts) | 0 / 98 |
Every entry is done or todo; there is no exclusion list and nothing is "out of scope".
What is modeled today:
- OAuth 2.0 — the client-credentials, authorization-code and refresh-token grants, HTTP Basic
app authentication, and RFC 6749's
invalid_client/invalid_grant/unsupported_grant_typerefusals. - Identity —
GET /v1/identity/openidconnect/userinfo?schema=openid, with claims gated by the scopes the person actually consented to, andPOST /v1/identity/openidconnect/tokenservice, the path PayPal's ownpaypal-rest-sdkposts a Log-in-with-PayPal code to (the same exchange as/v1/oauth2/token). - Log in with PayPal — the consent screen as HTML at the vendor's own path, the grant and deny redirects, the unregistered-return-URL error page, and single-use authorization codes.
- Batch Payouts — all four operations the vendor's OpenAPI document declares, plus the lifecycle, the validation and the duplicate-batch refusal.
- Webhooks Management — subscriptions (create/list/show/patch/delete), the event-type
catalogue, the events API, resend,
simulate-event,verify-webhook-signature, and the certificate endpoint.
What is not, in the areas that ARE served — each a todo in the manifest: the
PayPal-Request-Id idempotency header; currency_conversion; the VENMO recipient wallet and the
PHONE / PAYPAL_ID recipient types; an insufficient-funds denial; the webhooks-lookup family;
PayPal's whole published event-name catalogue (this pack carries the Batch payouts and Log in with
PayPal sections); webhooks-events search filters and paging; a 429 RATE_LIMIT_REACHED; and the
Log-in-with-PayPal endsession logout.
A claim is a routing promise. Every path the claimed paypal-rest-sdk touches is inside this
pack's host rules even where the operation is unbuilt — endsession is claimed and refuses loudly
(paypal.identity.endsession) rather than being left unclaimed, because an unclaimed path on a
claimed host is not routed at all and leaves the world for the real PayPal.
What is grounded, and what is the twin's own choice
The payouts and webhooks halves are grounded in PayPal's first-party OpenAPI documents
(github.com/paypal/paypal-rest-api-specifications, payments_payouts_batch_v1.json "Payouts" 1.9
and notifications_webhooks_v1.json "Webhooks Management" 1.11, fetched 2026-09-13). PayPal
publishes no spec for the OAuth, Identity and Log-in-with-PayPal halves, so those were grounded
top-down from developer.paypal.com on the same date. census.json's spec slice carries the
full provenance.
Four things are the twin's own decision rather than a vendor fact, said out loud at their seam and filed as todos rather than dressed up:
- the per-payout fee (
0.25) — PayPal publishes no schedule, only when a fee appears (paypal.payouts.real_fee_schedule); - the duplicate window — PayPal refuses a reused
sender_batch_idfor 30 days; the twin refuses for the life of the world, because a log has no rolling window and refusing more is the safe direction on a payout (paypal.payouts.duplicate_window); - the
issuestring on that refusal, which the vendor documents in prose but does not name in either schema (paypal.payouts.duplicate_batch_issue_name); - the
AUTHENTICATION_FAILURE401 pair, taken from PayPal's REST error catalogue because neither OpenAPI document carries a 401 for these operations (paypal.errors.pin_401_pair).
The human surface
This pack is in the "the vendor UI is part of the protocol" class, with googleoauth and
xidentity: Log in with PayPal's core job is a human, in a browser, at www.paypal.com, reading what
an app is asking for and pressing a button. The authorization-code flow is defined by that
redirect, so the twin serves the screen as HTML at the vendor's real path, rendered from the kernel
projection by the one state builder (paypalConsentState) the capability verifies read — one
renderer, so API↔UI parity cannot drift. The page is two plain <form> submits and carries no
JavaScript at all, which is why this pack ships no browser bundle.
This is not a dashboard mirror. PayPal's merchant dashboard is a real product surface and this twin does not model it; no capability here claims it.
The connector
Pull-only, deliberately. It observes the REST app's webhook subscriptions, its recent
notifications, and any payout batch the caller names — PayPal publishes no list endpoint for
payouts at all, which is a bound on the vendor's read surface rather than a shortcut, and it is
filed as paypal.connector.pull_payout_index.
Push is a refusal, not an unfinished half. Performing a local payout batch against a real
PayPal account creates a real payout: money leaving a real balance for real recipients. No sync
gets to decide that, so performPaypalAction throws — which is the only way to refuse. In the
kernel a return is a success: the entry is stamped receipt: { status: 'deployed' } and the
local id is adopted as the vendor's, so returning { performed: false } would leave the log
claiming a payout deployed to PayPal while nothing crossed. A throw is what produces the failed
receipt and the revert. pushPendingPaypalActions reports how much local state has no upstream
home (paypal.connector.push).
Every live call goes through livePaypalExecute, the pack's one fetch site, guarded fail-closed by
the shared kernel rate budget. PayPal publishes no rate-limiting policy — its own page says so
— so the declaration stays at the kernel's austere fallback and redistributes weight only by blast
radius: a payout create costs a fifth of the window.
Protocol
Protocol 2: the pack registers its own half of the real state system on load — perform,
refresh, a roundTrip the kernel can send blind, and a parityOrigin.
shapeParity is not held, and the reason is structural rather than a divergence to close: the
refresh observes webhook subscriptions, recent notifications and any payout batch it is told about,
while the only write a blind round trip can make on this vendor is registering a REST app — which
PayPal publishes no API to read back. Written and observed are different subjects by design.
One thing worth knowing about the refresh: a blind one (no credential the twin minted, which is
what the parity harness sends) observes nothing and says so on stderr, rather than crashing.
That is not the "a refused pull is not an empty account" hazard inverted — zero resources are
handed to the kernel, so no row can be overwritten and no delta can land. Every other refusal
still throws, at both layers: livePaypalExecute on any >= 400 and on a 2xx carrying PayPal's
error envelope, and collectPaypal on a reply whose shape is not the vendor's.
