npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 4310

Three 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 that new crypto.X509Certificate(pem) parses and crypto.createVerify('SHA256').verify(certPem, sig) accepts. A receiver verifying offline with unmodified code verifies a twin delivery unchanged — including the misconfiguration case, where the wrong PAYPAL_WEBHOOK_ID correctly 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 at PROCESSING, time_completed/time_closed at SUCCESS, and the matching PAYMENT.PAYOUTS-ITEM.* / PAYMENT.PAYOUTSBATCH.* notifications raised as it moves.
  • The refusals. The duplicate sender_batch_id PayPal 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 token userinfo declines 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_type refusals.
  • Identity — GET /v1/identity/openidconnect/userinfo?schema=openid, with claims gated by the scopes the person actually consented to, and POST /v1/identity/openidconnect/tokenservice, the path PayPal's own paypal-rest-sdk posts 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_id for 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 issue string on that refusal, which the vendor documents in prose but does not name in either schema (paypal.payouts.duplicate_batch_issue_name);
  • the AUTHENTICATION_FAILURE 401 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.