Simulators
PayPal simulator
paypal-twin is a local, stateful simulator of PayPal checkout for testing code that takes orders, authorizes and captures payments and issues refunds. Orders v2, payment authorizations, captures, refunds and OAuth2 tokens keep the lifecycles, amounts, fee breakdowns and errors of PayPal’s sandbox, called PayPal below. The Limitations section and Differs from PayPal lines beside each behavior mark known departures from PayPal. The simulator never calls PayPal, moves no money and delivers no webhook.
Quickstart
Install the CLI, sign in and boot the simulator with RYSTIC_URL pointing at it.
On Pro you sign in with an API key from your account .
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_… # your API key
export RYSTIC_URL=$(rystic run paypal-twin -d --port 0 --scenario design_studio --json | jq -r .data.url)
curl -s "$RYSTIC_URL/_rystic/version"
# -> {..."product":"paypal-twin"...}
On Enterprise, the licensed edition signs in with a license file in place of the key: rystic login ./rystic-license.json (licensing ).
First calls
Read the order that holds the design studio’s largest deposit, refund part of the deposit’s capture and read the capture back:
curl -s "$RYSTIC_URL/v2/checkout/orders/YOYGVT4Z8IN84MYDR" -H 'Authorization: Bearer offline-test' | jq -c '{id, status, invoice: .purchase_units[0].invoice_id}'
# -> {"id":"YOYGVT4Z8IN84MYDR","status":"COMPLETED","invoice":"WDC-1220"}
curl -s "$RYSTIC_URL/v2/payments/captures/F5AYSKF3C4TRMAC25/refund" -H 'Authorization: Bearer offline-test' -H 'Content-Type: application/json' -H 'PayPal-Request-Id: refund-1' -d '{"amount":{"currency_code":"USD","value":"500.00"}}' | jq -c '{id, status, value: .amount.value}'
# -> {"id":"RF000000000000001","status":"COMPLETED","value":"500.00"}
curl -s "$RYSTIC_URL/v2/payments/captures/F5AYSKF3C4TRMAC25" -H 'Authorization: Bearer offline-test' | jq -c '{id, status}'
# -> {"id":"F5AYSKF3C4TRMAC25","status":"PARTIALLY_REFUNDED"}
Amounts are decimal strings in the currency’s own unit, as on PayPal, so "500.00" is $500.00.
Connect your client
Point a plain HTTP client at $RYSTIC_URL and send any non-empty bearer token.
PayPal’s official libraries, Python and Node among them, take an environment but no base URL. They reach the simulator through its intercept gateway (Intercept vendor calls):
- Boot the simulator with
rystic run paypal-twin --intercept -d --port 0 --scenario design_studio. - Start your program with
rystic exec -- <command>.
The library keeps its sandbox host, api-m.sandbox.paypal.com, and the gateway serves that host from the simulator. api.paypal.com isn’t routed; api-m.paypal.com and the sandbox hosts are (Intercept). Python:
from paypalserversdk.configuration import Environment
from paypalserversdk.http.auth.o_auth_2 import ClientCredentialsAuthCredentials
from paypalserversdk.paypal_serversdk_client import PaypalServersdkClient
client = PaypalServersdkClient(
client_credentials_auth_credentials=ClientCredentialsAuthCredentials(
o_auth_client_id="client-id",
o_auth_client_secret="client-secret",
),
environment=Environment.SANDBOX,
)
order = client.orders.get_order({"id": "YOYGVT4Z8IN84MYDR"})
print(order.body.status)
Node:
import { Client, Environment, OrdersController } from "@paypal/paypal-server-sdk";
const client = new Client({
clientCredentialsAuthCredentials: {
oAuthClientId: "client-id",
oAuthClientSecret: "client-secret",
},
environment: Environment.Sandbox,
});
const { result } = await new OrdersController(client).getOrder({ id: "YOYGVT4Z8IN84MYDR" });
console.log(result.status);
The Node library needs a current axios under rystic exec (Intercept).
Send Prefer: return=representation for the full resource. Without it, creating an order returns only the order’s id, status and links, plus its payment source and purchase units when the request names a payment_source.
Each library first mints its token at POST /v1/oauth2/token, and the simulator mints one for any client id and secret. If the live feed shows none, the program did not start under rystic exec.
No client has been checked against this simulator yet.
Auth
The simulator checks the shape of your credential, not its value, so any non-empty token works, except a token the table below marks refused.
Differs from PayPal: PayPal verifies each credential.
The payment routes (orders, authorizations, captures and refunds) take Authorization: Bearer <token>. POST /v1/oauth2/token takes HTTP Basic with any non-empty client id and secret.
| Request | Returns |
|---|---|
| A payment route with no bearer token and no Basic credential | 401 AUTHENTICATION_FAILURE, before any lookup |
The order read, GET /v2/checkout/orders/{id}, with HTTP Basic and your client id and secret | the order |
| Any other payment route with HTTP Basic | 401 |
POST /v1/oauth2/token with grant_type=client_credentials | a new bearer token |
POST /v1/oauth2/token with no Basic credentials | 401 invalid_client |
POST /v1/oauth2/token with a missing or other grant type | 400 unsupported_grant_type |
GET /v1/oauth2/token with a bearer this simulator minted | that token’s metadata, its expires_in counted down on the simulator’s clock |
GET /v1/oauth2/token with any other bearer, or a minted one whose expires_in has run out | 401 invalid_token |
GET /v1/oauth2/token with no bearer | 401 invalid_client |
Six routes refuse every request, with or without a credential (Error format).
A first token request, curl -u my-client-id:my-secret "$RYSTIC_URL/v1/oauth2/token" -d grant_type=client_credentials, returns:
access_tokenA21AA000000000000000000000000000001token_typeBearerexpires_in32400
A minted token runs out once its expires_in passes on the simulator’s clock. To test a dead token, seed one in state_oauth_token with a state of expired, revoked or inactive. Each payment route decides which of these it refuses, with 401 AUTHENTICATION_FAILURE:
| Route | A token whose issued_at plus expires_in has passed | expired | revoked | inactive |
|---|---|---|---|---|
| creating or updating an order | accepted | refused | refused | refused |
| authorizing or capturing an order | accepted | refused | accepted | accepted |
| reading or voiding an authorization, reading a refund | refused | refused | refused | accepted |
| reading an order or a capture, capturing or reauthorizing an authorization, refunding a capture | accepted | accepted | accepted | accepted |
Differs from PayPal: a route the table marks accepted takes a token PayPal refuses.
API version
The simulator serves Orders v2, Payments v2 and OAuth2 v1, the versions PayPal carries in each path, and has no version to pin: PayPal sends no version header and reads none.
A path under another version, such as /v1/checkout/orders, is not served and returns 404.
Errors and limits
Error format
An error is PayPal’s JSON error object. Most have name, message, debug_id, details and links, but some leave out the last three, so check the name first. An information_link leads to a path the simulator doesn’t serve (Limitations). These have another shape:
| Request or error | Returns |
|---|---|
401 AUTHENTICATION_FAILURE | name, message and links, with no debug_id |
400 DUPLICATE_REQUEST_ID | name, message, debug_id and a top-level information_link, with no links or details |
an error from /v1/oauth2/token | OAuth2’s error and error_description |
DELETE on an order | 405 METHOD_NOT_SUPPORTED with a bearer; 401 AUTHENTICATION_FAILURE without one |
PUT or OPTIONS on an order, GET on an order’s capture or on a capture’s refund, PATCH on an authorization | 404 with an empty body, with or without a bearer |
An unserved route returns the simulator’s own 404, and most responses lack the PayPal-Debug-Id header: both are known issues (Limitations).
Rate limits
Network profiles send a 429 you shape, such as PayPal’s RATE_LIMIT_REACHED; the simulator throttles nothing on its own (Limitations).
Pagination
Not applicable: PayPal’s Orders v2 and Payments v2 have no list routes, so every order, authorization, capture and refund is reached by its id. GET /_rystic/inspect lists them all.
Idempotency
A PayPal-Request-Id header makes a retry safe when you create an order, authorize, capture, reauthorize, void or refund. A retry with the same id and body makes nothing new and returns this:
| You retry | The retry returns |
|---|---|
| creating an order | the order as it stands now, in the form the first request’s Prefer chose (unconfirmed, meaning not yet checked against PayPal) |
| capturing an authorization | the capture as it stands now |
| reauthorizing | the new authorization’s id, links and current status |
| authorizing or capturing an order, voiding or refunding | the stored response |
refunding another capture with the PayPal-Request-Id an earlier refund sent | 400 DUPLICATE_REQUEST_ID |
Keys for creating and capturing an order lapse after 6 hours on the simulator’s clock. A card order needs the header and can complete when it is created. A wallet order without it departs from PayPal, a known issue under Limitations.
Coverage
Disputes, payouts, subscriptions, invoicing, vaulted payment tokens, identity, order tracking and confirm-payment-source are out of scope.
20 routes, counted from the simulator’s route table.
Show all operations
| Resource | Operations |
|---|---|
| Authorized payment (4) | GET, PATCH /v2/payments/authorizations/{authorization_id}; POST /v2/payments/authorizations/{authorization_id}/reauthorize; POST /v2/payments/authorizations/{authorization_id}/void |
| Captured payment (4) | GET, POST /v2/checkout/orders/{id}/capture; POST /v2/payments/authorizations/{authorization_id}/capture; GET /v2/payments/captures/{capture_id} |
| OAuth2 access token store (2) | GET, POST /v1/oauth2/token |
| Order (7) | POST /v2/checkout/orders; GET, PUT, PATCH, DELETE, OPTIONS /v2/checkout/orders/{id}; POST /v2/checkout/orders/{id}/authorize |
| Refund (3) | GET, POST /v2/payments/captures/{capture_id}/refund; GET /v2/payments/refunds/{refund_id} |
Feed effects. Each operation below can put an effect event on the event feed (/_rystic/events) for each event type beside it, when what the call does produces that event. Any other call puts only its request event there.
| Operation | Event types |
|---|---|
POST /v2/payments/authorizations/{authorization_id}/capture | PAYMENT.CAPTURE.COMPLETED |
POST /v2/payments/authorizations/{authorization_id}/void | authorization_voided |
POST /v2/payments/captures/{capture_id}/refund | PAYMENT.CAPTURE.REFUNDED |
Scenarios
The simulator ships 1 scenario; the quickstart boots design_studio.
| Scenario | What it is for | What it seeds | Clock |
|---|---|---|---|
design_studio (default) | Website Design Contractors’ PayPal business account | capture 57, order 57 | 2026-03-01T00:00:00Z |
What each scenario holds:
design_studio:- 57 COMPLETED orders, one per invoice its 8 PayPal clients paid, each with its capture.
invoice_idis the WDC invoice number,custom_idthe client and the payer its billing contact.- Saltmarsh Brewing’s $14,500.00 deposit (WDC-1220), captured 2026-02-24.
- No order yet for the 2 open invoices, WDC-1298 and WDC-1330.
Boot one by name, or load one into a running simulator:
rystic run paypal-twin -d --port 0 --scenario design_studio
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"design_studio"}'
The account’s timeline
Moving the clock plays design_studio’s timeline, each step a call on the simulator’s own API once the clock passes its offset from the scenario’s start. Events, in order: WDC-1298 gets an order (+1h) → WDC-1330 is charged (+1d) → $500.00 of Saltmarsh Brewing’s deposit is refunded (+2d) → Quill and Ink Stationery’s care plan is authorized (+3d) → captured (+5d).
The billing bot opens the WDC-1298 order, left CREATED for Wildflower Daycare to approve, charges Oakridge Bakery’s saved wallet for WDC-1330 and authorizes the care plan against Quill and Ink’s saved wallet. The owner makes the refund and the capture. So once the clock passes them, the timeline’s records hold the first order, capture and authorization ids.
design_studio binds two bearer tokens: A21AA8B_XHb54rOgHxbMAFaR5MR891qAE44wWxV0_WiUH acts as the owner, as any other token does, and A21AAJuksmYHTvSSCsowDzm7hmWqv9kE-JBMkWdfa1VzW as the billing bot.
What the scenarios do not hold
- No order in
APPROVED(Payment lifecycle). - No authorization at boot: create a card
AUTHORIZEorder, which completes with one (Payment lifecycle).
Seeding state
This simulator has six registers, each record under its key:
| Register | Holds | Keyed by |
|---|---|---|
state_order | orders | id |
state_authorization | authorizations | id |
state_capture | captures | id |
state_refund | refunds | id |
state_oauth_token | OAuth2 tokens | access_token |
state_idempotency_keys | a record of each successful write that Idempotency names, sent with a PayPal-Request-Id | paypal_request_id and operation |
Seed an approved order and read it back:
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$RYSTIC_URL/_rystic/seed" -d '{"state_order": {"ORDAPPROVED1": {"id": "ORDAPPROVED1", "intent": "CAPTURE", "status": "APPROVED", "create_time": "2026-01-01T00:00:00Z", "update_time": "2026-01-01T00:00:00Z", "purchase_units": [{"reference_id": "default", "amount": {"currency_code": "USD", "value": "25.00"}, "payee": {"merchant_id": "MERCHANT1"}}]}}}'
# -> 204
curl -s "$RYSTIC_URL/v2/checkout/orders/ORDAPPROVED1" -H 'Authorization: Bearer offline-test' | jq -c '{id, status}'
# -> {"id":"ORDAPPROVED1","status":"APPROVED"}
Seeding covers the seed route, boot files and what a seed refuses. Payment lifecycle captures the approved order.
Webhooks
Three operations write an event to an ordered log, which GET /_rystic/egress returns, so a test can assert the event your integration would have received.
Differs from PayPal: the simulator delivers no webhooks, so it never calls your endpoints.
Differs from PayPal: the simulator has no /v1/notifications route.
| Operation | Event | Its payload holds |
|---|---|---|
Capturing an authorization, POST /v2/payments/authorizations/{authorization_id}/capture | PAYMENT.CAPTURE.COMPLETED | id, event_type, event_version, create_time, resource_type, resource_version, summary, resource and links |
Refunding a capture, POST /v2/payments/captures/{capture_id}/refund | PAYMENT.CAPTURE.REFUNDED | id, event_type, event_version, create_time, resource_type, summary and resource |
Voiding an authorization, POST /v2/payments/authorizations/{authorization_id}/void | authorization_voided | id, event_type, create_time and resource |
Differs from PayPal: the capture event’s links holds one resource link to the capture, where PayPal’s event links self and resend.
Differs from PayPal: the refund event’s payload has no resource_version or links, which PayPal’s event carries.
Differs from PayPal: the capture and refund events’ summary writes the amount as USD 10.00, where PayPal’s examples write $ 10.0 USD.
Differs from PayPal: the void event’s name is the simulator’s own, where PayPal’s is PAYMENT.AUTHORIZATION.VOIDED.
Differs from PayPal: the void event’s payload holds only the four fields the table shows, where PayPal’s carries the nine of the capture row.
Capturing an order (POST /v2/checkout/orders/{id}/capture) and creating a card order record no event. Observing covers reading the log.
Features
Each feature has its own page:
- PayPal Business UI: The account drawn as PayPal’s merchant dashboard in your browser, redrawn as your integration calls the API, with the merchant’s own actions.
- Payment lifecycle: Orders, authorizations, captures and refunds keep PayPal’s states, fee breakdowns and errors; an order is approved by a seed, and a card order can complete at creation.
Determinism and reset
Ids are sequences, such as ORD00000000000001, AUT00000000000001 and RF000000000000001, and debug ids are a sequence too. Each request that reads the clock, which every write does, adds one millisecond. Moving the clock plays the account’s timeline.
Reset and determinism covers reset, the clock and the RNG.
Limitations
Known issues.
- A new order returns 202 when its
payment_sourceis an alternative payment method, such asidealorsofort, and 201 otherwise (unconfirmed): PayPal documents a 202 but not which requests get it. Workaround: Accept either status. - A wallet order (
payment_source.paypal) sent withoutPayPal-Request-Idreturns 400PAYPAL_REQUEST_ID_REQUIRED; PayPal needs the header only for a card source and returns 200PAYER_ACTION_REQUIRED. PayPal-Mock-Responseis ignored, so a request asking for a declined instrument or an internal error succeeds. A refund sent withoutPreferreturns the full refund, where PayPal returns the minimal one.- An order whose
amount.valueis a JSON number (10.00) returns 400INVALID_PARAMETER_SYNTAX; PayPal accepts it and stores the string"10.00". - Most responses, an order’s among them, carry no
PayPal-Debug-Idheader; PayPal sends it, equal to the body’sdebug_id. - A route the simulator does not serve returns 404 with a JSON message; PayPal returns 404 with an empty body.
- An order read with
fields=payment_sourcereturnslinks: null; PayPal omitslinks.
Beyond the known issues, 5 of the 25 operations in the published spec are not served, 84 documented responses have no test yet and 66 behaviors are not modeled. The details follow.
Show all limitations
Not served. 5 of the 25 operations in the published spec are not served.
- orders, 5 of 7 served:
POST /v2/checkout/orders/{id}/confirm-payment-source,POST /v2/checkout/orders/{id}/track - Not served at all: externalcallback (1), find-eligible-methods (1), trackers (1)
Not tested. 110 tests compare the simulator with the real service. 84 documented responses have no test yet, and 13 of them need a credential our test account doesn’t hold (401 ×1, 403 ×12). The other 71:
| Operation | Status | Why |
|---|---|---|
DELETE /v2/checkout/orders/{id} | 405 | no known request reaches it |
GET /v2/checkout/orders/{id} | 500 | no request a client sends provokes it |
GET /v2/payments/authorizations/{authorization_id} | 200 | no test could build the request it needs |
GET /v2/payments/authorizations/{authorization_id} | 500 | no request a client sends provokes it |
GET /v2/payments/captures/{capture_id} | 200 | no test could build the request it needs |
GET /v2/payments/refunds/{refund_id} | 200 | no test could build the request it needs |
GET /v2/payments/refunds/{refund_id} | 500 | no request a client sends provokes it |
PATCH /v2/checkout/orders/{id} | 400 | a well-formed request does not provoke it (3 cases) |
PATCH /v2/checkout/orders/{id} | 422 | no known request reaches it (7 cases) |
PATCH /v2/checkout/orders/{id} | 500 | no request a client sends provokes it |
POST /v2/checkout/orders | 400 | a well-formed request does not provoke it (3 cases) |
POST /v2/checkout/orders | 415 | no known request reaches it |
POST /v2/checkout/orders/{id}/authorize | 200 | no known request reaches it |
POST /v2/checkout/orders/{id}/authorize | 201 | no known request reaches it |
POST /v2/checkout/orders/{id}/authorize | 400 | a well-formed request does not provoke it |
POST /v2/checkout/orders/{id}/authorize | 422 | no known request reaches it |
POST /v2/checkout/orders/{id}/authorize | 500 | no request a client sends provokes it |
POST /v2/checkout/orders/{id}/capture | 200 | no known request reaches it |
POST /v2/checkout/orders/{id}/capture | 201 | no known request reaches it |
POST /v2/checkout/orders/{id}/capture | 400 | a well-formed request does not provoke it (2 cases) |
| … and 24 more |
Not modeled. Behavior the simulator does not model, by operation:
- PayPal-Auth-Assertion act-on-behalf-of resolution: the simulator does not decode the JWT or switch merchant context. (
GET /v2/checkout/orders/{id}) - The
defaultcatch-all response: byte-identical to the 500 example and documented as ‘should not be encountered in practice’. (GET /v2/checkout/orders/{id}) fieldsfiltering semantics: the response projection produced by ?fields=payment_source is modeled as the full order document, not a filtered subset. (GET /v2/checkout/orders/{id})- purchase_units[].id, which PayPal mints only when the order is saved via /v2/checkout/orders/{id}/save (not in scope). (
GET /v2/checkout/orders/{id}) - Rate limiting (429) is not documented on this operation and is therefore not modeled. (
GET /v2/payments/authorizations/{authorization_id}) - disbursement_mode DELAYED and the delayed-disbursement settlement timeline. (
GET /v2/payments/captures/{capture_id}) - processor_response and network_transaction_reference sub-objects. (
GET /v2/payments/captures/{capture_id}) - Rate limiting (429) is not documented on this operation and is not modeled. (
GET /v2/payments/refunds/{refund_id}) - The documented 200 example carries a
notefield that the refund schema does not define (the schema names the caller-facing reasonnote_to_payer); which key the live service returns is unverified, so onlynote_to_payeris modeled. (GET /v2/payments/refunds/{refund_id}) - Level 2 and Level 3 card data added via purchase_units[].supplementary_data.card patches: listed in the patchable-attribute table but carries no documented response or error behavior here. (
PATCH /v2/checkout/orders/{id}) - Whether any Content-Type, body bytes or credential state alters the refusal. (
PATCH /v2/payments/authorizations/{authorization_id}) - 207 Multi-Status — mixed per-purchase-unit outcomes on a multi-purchase-unit order (purchase_units is 1..10). (
POST /v2/checkout/orders/{id}/authorize) - Prefer: return=representation vs return=minimal changes the SIZE of the success body (the spec says minimal is id/status/links). (
POST /v2/checkout/orders/{id}/authorize) - The
defaultresponse (components/responses/default_response) — a catch-all with no distinct status. (POST /v2/checkout/orders/{id}/authorize) - 202 Accepted — ‘the request is valid and will be handled asynchronously’. (
POST /v2/checkout/orders/{id}/capture) - 207 Multi-Status — mixed per-purchase-unit outcomes on a multi-purchase-unit order. (
POST /v2/checkout/orders/{id}/capture) - 500 error_response and the
defaultresponse — infrastructure failures rather than caller-observable modeled behavior. (POST /v2/checkout/orders/{id}/capture) - PayPal-Auth-Assertion changes which merchant the order is read under (partner acting on behalf of a seller). (
POST /v2/checkout/orders/{id}/capture) - PayPal-Client-Metadata-Id feeds risk decisioning only; no documented response difference. (
POST /v2/checkout/orders/{id}/capture) - payment_instruction (platform_fees, disbursement_mode, payee_receivable/payee_pricing_tier semantics) is carried structurally only; partner fee economics are not modeled. (
POST /v2/payments/authorizations/{authorization_id}/capture) - … and 46 more
By design.
- No throttling. No request returns PayPal’s 429
RATE_LIMIT_REACHEDon its own. - Links lead back to the simulator, which serves only the API. Every
links[].hrefand errorinformation_linkkeeps PayPal’s path on the simulator’s origin, so followingself,captureorrefundworks. A buyerapprovelink and a documentation link land on a path the simulator does not serve and return 404.
Release notes: Changelog