Skip to Content
SimulatorsPayPalOverview

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):

  1. Boot the simulator with rystic run paypal-twin --intercept -d --port 0 --scenario design_studio.
  2. 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.

RequestReturns
A payment route with no bearer token and no Basic credential401 AUTHENTICATION_FAILURE, before any lookup
The order read, GET /v2/checkout/orders/{id}, with HTTP Basic and your client id and secretthe order
Any other payment route with HTTP Basic401
POST /v1/oauth2/token with grant_type=client_credentialsa new bearer token
POST /v1/oauth2/token with no Basic credentials401 invalid_client
POST /v1/oauth2/token with a missing or other grant type400 unsupported_grant_type
GET /v1/oauth2/token with a bearer this simulator mintedthat 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 out401 invalid_token
GET /v1/oauth2/token with no bearer401 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_token A21AA000000000000000000000000000001
  • token_type Bearer
  • expires_in 32400

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:

RouteA token whose issued_at plus expires_in has passedexpiredrevokedinactive
creating or updating an orderacceptedrefusedrefusedrefused
authorizing or capturing an orderacceptedrefusedacceptedaccepted
reading or voiding an authorization, reading a refundrefusedrefusedrefusedaccepted
reading an order or a capture, capturing or reauthorizing an authorization, refunding a captureacceptedacceptedacceptedaccepted

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 errorReturns
401 AUTHENTICATION_FAILUREname, message and links, with no debug_id
400 DUPLICATE_REQUEST_IDname, message, debug_id and a top-level information_link, with no links or details
an error from /v1/oauth2/tokenOAuth2’s error and error_description
DELETE on an order405 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 authorization404 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 retryThe retry returns
creating an orderthe order as it stands now, in the form the first request’s Prefer chose (unconfirmed, meaning not yet checked against PayPal)
capturing an authorizationthe capture as it stands now
reauthorizingthe new authorization’s id, links and current status
authorizing or capturing an order, voiding or refundingthe stored response
refunding another capture with the PayPal-Request-Id an earlier refund sent400 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
ResourceOperations
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.

OperationEvent types
POST /v2/payments/authorizations/{authorization_id}/capturePAYMENT.CAPTURE.COMPLETED
POST /v2/payments/authorizations/{authorization_id}/voidauthorization_voided
POST /v2/payments/captures/{capture_id}/refundPAYMENT.CAPTURE.REFUNDED

Scenarios

The simulator ships 1 scenario; the quickstart boots design_studio.

ScenarioWhat it is forWhat it seedsClock
design_studio (default)Website Design Contractors’ PayPal business accountcapture 57, order 572026-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_id is the WDC invoice number, custom_id the 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

Seeding state

This simulator has six registers, each record under its key:

RegisterHoldsKeyed by
state_orderordersid
state_authorizationauthorizationsid
state_capturecapturesid
state_refundrefundsid
state_oauth_tokenOAuth2 tokensaccess_token
state_idempotency_keysa record of each successful write that Idempotency names, sent with a PayPal-Request-Idpaypal_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.

OperationEventIts payload holds
Capturing an authorization, POST /v2/payments/authorizations/{authorization_id}/capturePAYMENT.CAPTURE.COMPLETEDid, event_type, event_version, create_time, resource_type, resource_version, summary, resource and links
Refunding a capture, POST /v2/payments/captures/{capture_id}/refundPAYMENT.CAPTURE.REFUNDEDid, event_type, event_version, create_time, resource_type, summary and resource
Voiding an authorization, POST /v2/payments/authorizations/{authorization_id}/voidauthorization_voidedid, 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_source is an alternative payment method, such as ideal or sofort, 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 without PayPal-Request-Id returns 400 PAYPAL_REQUEST_ID_REQUIRED; PayPal needs the header only for a card source and returns 200 PAYER_ACTION_REQUIRED.
  • PayPal-Mock-Response is ignored, so a request asking for a declined instrument or an internal error succeeds. A refund sent without Prefer returns the full refund, where PayPal returns the minimal one.
  • An order whose amount.value is a JSON number (10.00) returns 400 INVALID_PARAMETER_SYNTAX; PayPal accepts it and stores the string "10.00".
  • Most responses, an order’s among them, carry no PayPal-Debug-Id header; PayPal sends it, equal to the body’s debug_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_source returns links: null; PayPal omits links.

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:

OperationStatusWhy
DELETE /v2/checkout/orders/{id}405no known request reaches it
GET /v2/checkout/orders/{id}500no request a client sends provokes it
GET /v2/payments/authorizations/{authorization_id}200no test could build the request it needs
GET /v2/payments/authorizations/{authorization_id}500no request a client sends provokes it
GET /v2/payments/captures/{capture_id}200no test could build the request it needs
GET /v2/payments/refunds/{refund_id}200no test could build the request it needs
GET /v2/payments/refunds/{refund_id}500no request a client sends provokes it
PATCH /v2/checkout/orders/{id}400a well-formed request does not provoke it (3 cases)
PATCH /v2/checkout/orders/{id}422no known request reaches it (7 cases)
PATCH /v2/checkout/orders/{id}500no request a client sends provokes it
POST /v2/checkout/orders400a well-formed request does not provoke it (3 cases)
POST /v2/checkout/orders415no known request reaches it
POST /v2/checkout/orders/{id}/authorize200no known request reaches it
POST /v2/checkout/orders/{id}/authorize201no known request reaches it
POST /v2/checkout/orders/{id}/authorize400a well-formed request does not provoke it
POST /v2/checkout/orders/{id}/authorize422no known request reaches it
POST /v2/checkout/orders/{id}/authorize500no request a client sends provokes it
POST /v2/checkout/orders/{id}/capture200no known request reaches it
POST /v2/checkout/orders/{id}/capture201no known request reaches it
POST /v2/checkout/orders/{id}/capture400a 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 default catch-all response: byte-identical to the 500 example and documented as ‘should not be encountered in practice’. (GET /v2/checkout/orders/{id})
  • fields filtering 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 note field that the refund schema does not define (the schema names the caller-facing reason note_to_payer); which key the live service returns is unverified, so only note_to_payer is 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 default response (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 default response — 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_REACHED on its own.
  • Links lead back to the simulator, which serves only the API. Every links[].href and error information_link keeps PayPal’s path on the simulator’s origin, so following self, capture or refund works. A buyer approve link and a documentation link land on a path the simulator does not serve and return 404.

Release notes: Changelog

Last updated on