# PayPal simulator

A local PayPal checkout for payment code: Orders v2, authorizations, captures, refunds and OAuth2 tokens, with seeded payment histories.

`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

<!-- BEGIN generated: quickstart (go run ./ingest/cmd/twindocs paypal; do not edit) -->

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](https://www.rystic.ai/account/api-keys).

```bash
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](https://www.rystic.ai/docs/licensing)).

<!-- END generated: quickstart -->

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

```bash
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](https://www.rystic.ai/docs/intercept.md)):

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](https://www.rystic.ai/docs/intercept.md)). Python:

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

```js
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](https://www.rystic.ai/docs/intercept.md)).

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](https://www.rystic.ai/docs/concepts/observing.md#live-feed) shows none, the program did not start under `rystic exec`.

<!-- BEGIN generated: known-clients (go run ./ingest/cmd/twindocs paypal; do not edit) -->

No client has been checked against this simulator yet.

<!-- END generated: known-clients -->

## 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](#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`:

| 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](#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](https://www.rystic.ai/docs/control-plane.md#status-codes) returns the simulator's own 404, and most responses lack the `PayPal-Debug-Id` header: both are known issues ([Limitations](#limitations)).

### Rate limits

[Network profiles](https://www.rystic.ai/docs/network-profiles.md) send a 429 you shape, such as PayPal's `RATE_LIMIT_REACHED`; the simulator throttles nothing on its own ([Limitations](#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](#limitations).

## Coverage

Disputes, payouts, subscriptions, invoicing, vaulted payment tokens, identity, order tracking and `confirm-payment-source` are out of scope.

<!-- BEGIN generated: served-operations (go run ./ingest/cmd/twindocs paypal; do not edit) -->

<!-- coverage-claim -->
20 routes, counted from the simulator's route table.

| 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}` |

<!-- END generated: served-operations -->

<!-- BEGIN generated: feed-effects (go run ./ingest/cmd/twindocs paypal; do not edit) -->

**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` |

<!-- END generated: feed-effects -->

## Scenarios

<!-- BEGIN generated: scenarios (go run ./ingest/cmd/twindocs paypal; do not edit) -->

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

```bash
rystic run paypal-twin -d --port 0 --scenario design_studio
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"design_studio"}'
```

<!-- END generated: scenarios -->

### 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](https://www.rystic.ai/docs/simulators/paypal/features/payment-lifecycle.md#orders-and-approval)).
- No authorization at boot: create a card `AUTHORIZE` order, which completes with one ([Payment lifecycle](https://www.rystic.ai/docs/simulators/paypal/features/payment-lifecycle.md)).

## 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](#idempotency) names, sent with a `PayPal-Request-Id` | `paypal_request_id` and `operation` |

Seed an approved order and read it back:

```bash
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](https://www.rystic.ai/docs/concepts/worlds.md#seeding) covers the seed route, boot files and what a seed refuses. [Payment lifecycle](https://www.rystic.ai/docs/simulators/paypal/features/payment-lifecycle.md) 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](https://www.rystic.ai/docs/concepts/observing.md) covers reading the log.

## Features

<!-- BEGIN generated: features (go run ./ingest/cmd/twindocs paypal; do not edit) -->

Each feature has its own page:

- [PayPal Business UI](https://www.rystic.ai/docs/simulators/paypal/features/business-ui.md): 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](https://www.rystic.ai/docs/simulators/paypal/features/payment-lifecycle.md): 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.

<!-- END generated: features -->

## 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](#the-accounts-timeline).

[Reset and determinism](https://www.rystic.ai/docs/concepts/reset-and-determinism.md) covers reset, the clock and the RNG.

## Limitations

<!-- BEGIN generated: known-gaps (go run ./ingest/cmd/twindocs paypal; do not edit) -->

**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`.

<!-- gaps-claim -->
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.

**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 `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

<!-- END generated: known-gaps -->

**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](https://www.rystic.ai/docs/boot-configuration.md#environment-variables), 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](https://www.rystic.ai/docs/simulators/paypal/changelog.md)

---

Source: https://www.rystic.ai/docs/simulators/paypal · Markdown: https://www.rystic.ai/docs/simulators/paypal.md
