> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deploybaseline.com/llms.txt
> Use this file to discover all available pages before exploring further.

# PayPal

> Orders v2, a hosted approval page, Payments v2, and webhooks.

Stateful PayPal emulation of the REST APIs a checkout integration uses: OAuth 2.0 client credentials, Orders v2, Payments v2 (authorizations, captures, refunds), and Webhooks v1 with PayPal's SHA256withRSA delivery signatures and a `verify-webhook-signature` endpoint that really verifies. The approve link of an order opens a hosted approval page where a seeded sandbox buyer approves or cancels and goes back to your `return_url` or `cancel_url`. Errors use PayPal's envelope (`name`, `message`, `debug_id`, `details[].issue`, `links`), and responses are checked against PayPal's published OpenAPI documents. The tests drive it with [`@paypal/paypal-server-sdk`](https://github.com/paypal/PayPal-TypeScript-Server-SDK) (2.5.0).

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/paypal` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `paypal` |
| HTTP operations | 33, browsable at `http://localhost:3999/docs/paypal` while it runs ([API reference](/api-reference.md)) |
| Vendor spec | [paypal/paypal-rest-api-specifications](https://github.com/paypal/paypal-rest-api-specifications) at `90e8041` |

```bash CLI theme={null}
npx @deploybaseline/cli start --service paypal
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const paypal = await createEmulator({ service: "paypal" });
// paypal.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "paypal": {
    "apps": [
      {
        "name": "My store",
        "client_id": "baseline-paypal-client-id",
        "client_secret": "baseline-paypal-client-secret",
        "brand_name": "Baseline Store",
        "webhooks": [
          {
            "url": "http://localhost:3000/api/paypal/webhook",
            "event_types": ["*"]
          }
        ]
      }
    ],
    "buyers": [
      {
        "email": "buyer@personal.example.com",
        "name": "John Doe"
      },
      {
        "email": "declined@personal.example.com",
        "name": "Dee Clined",
        "fail_with": "INSTRUMENT_DECLINED"
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service paypal
```

PayPal starts only when named in config or with `--service`. When it is the only enabled service, it listens on `http://localhost:4000`. Without a seed config there is one app (client id `baseline-paypal-client-id`, secret `baseline-paypal-client-secret`) and one sandbox buyer (`buyer@personal.example.com`).

`@paypal/paypal-server-sdk` always calls `api-m.paypal.com` or `api-m.sandbox.paypal.com`. Point it at the emulator with axios' fetch adapter and a fetch that swaps the origin:

```ts theme={null}
import { Client, Environment, OrdersController, CheckoutPaymentIntent } from "@paypal/paypal-server-sdk";

const emulator = new URL(process.env.PAYPAL_EMULATOR_URL!);
const client = new Client({
  clientCredentialsAuthCredentials: {
    oAuthClientId: "baseline-paypal-client-id",
    oAuthClientSecret: "baseline-paypal-client-secret",
  },
  environment: Environment.Sandbox,
  unstable_httpClientOptions: {
    adapter: "fetch",
    env: {
      fetch: async (input: string | URL | Request, init?: RequestInit) => {
        const url = new URL(input instanceof Request ? input.url : String(input));
        url.protocol = emulator.protocol;
        url.host = emulator.host;
        if (!(input instanceof Request)) return fetch(url, init);
        const body = input.body ? await input.arrayBuffer() : undefined;
        return fetch(url, { method: input.method, headers: input.headers, body });
      },
    },
  },
});

const orders = new OrdersController(client);
const { result } = await orders.createOrder({
  body: {
    intent: CheckoutPaymentIntent.Capture,
    purchaseUnits: [{ amount: { currencyCode: "USD", value: "100.00" } }],
  },
});
```

Plain HTTP clients and other SDKs only need the base URL changed.

## URL mapping

| Real PayPal URL | Emulator URL |
| - | - |
| `https://api-m.sandbox.paypal.com/v1/oauth2/token` | `$PAYPAL_EMULATOR_URL/v1/oauth2/token` |
| `https://api-m.sandbox.paypal.com/v2/checkout/orders` | `$PAYPAL_EMULATOR_URL/v2/checkout/orders` |
| `https://api-m.sandbox.paypal.com/v2/payments/...` | `$PAYPAL_EMULATOR_URL/v2/payments/...` |
| `https://api-m.sandbox.paypal.com/v1/notifications/...` | `$PAYPAL_EMULATOR_URL/v1/notifications/...` |
| `https://www.sandbox.paypal.com/checkoutnow?token={id}` | `$PAYPAL_EMULATOR_URL/checkoutnow?token={id}` |

## Auth

* `POST /v1/oauth2/token` — Get access token: HTTP Basic `client_id:client_secret` and `grant_type=client_credentials` (form or JSON)
* `POST /v1/oauth2/token/terminate` — Revoke access token: `token=<access token>`

The token call returns `scope`, `access_token`, `token_type: Bearer`, `app_id`, `expires_in` (32400, nine hours), and `nonce`. As on PayPal, the same token comes back while it is valid, with the seconds it has left. An optional `scope` narrows the token; a scope the app does not have answers `invalid_scope`. Terminating a token revokes it.

* A wrong client id or secret answers 401 `{"error":"invalid_client","error_description":"Client Authentication failed"}`.
* A missing grant type answers 400 `unsupported_grant_type` (`Grant Type is NULL`).
* An API call without a bearer token answers 401 `AUTHENTICATION_FAILURE`. An unknown or revoked token answers 401 `{"error":"invalid_token","error_description":"Token signature verification failed"}`, and an expired one `Access Token expired`.
* A token without a scope the call needs answers 403 `NOT_AUTHORIZED` with `PERMISSION_DENIED`, as does reading another app's order or payment.
* A bearer token the Baseline CLI was started with acts as the first app with all of its scopes.

## Orders v2

* `POST /v2/checkout/orders` — Create order: `intent` `CAPTURE` or `AUTHORIZE` and one to ten `purchase_units`. Amounts are checked as PayPal does: value syntax, currency decimals (`JPY`, `HUF`, and `TWD` take none), `amount.breakdown` against the total (`AMOUNT_MISMATCH`), items against `item_total` and `tax_total` (`ITEM_TOTAL_MISMATCH`, `TAX_TOTAL_MISMATCH`), one currency per unit, unique `reference_id`s when there are several units.
* `payment_source.paypal` (with `experience_context`: `return_url`, `cancel_url`, `brand_name`, `user_action`, `shipping_preference`) makes the order `PAYER_ACTION_REQUIRED` with a `payer-action` link. Without a payment source the order is `CREATED` with an `approve` link; `application_context` is read for the same fields.
* `payment_source.card` (`number`, `expiry`, `name`) makes the order `APPROVED` at once. `attributes.verification.method` of `SCA_ALWAYS` asks for 3D Secure: the order is `PAYER_ACTION_REQUIRED` until the buyer completes the step on the hosted page. Expired cards answer `CARD_EXPIRED`. Other sources answer `PAYMENT_SOURCE_NOT_SUPPORTED`.
* `GET /v2/checkout/orders/{id}` — Show order details
* `PATCH /v2/checkout/orders/{id}` — Update order: a JSON Patch array (`add`, `replace`, `remove`) on `/intent`, `/payer`, and purchase unit fields addressed by reference id, such as `/purchase_units/@reference_id=='default'/amount`, `.../shipping/address`, `.../invoice_id`, `.../custom_id`, `.../description`, `.../items`. Other paths answer `NOT_PATCHABLE`; malformed pointers `INVALID_JSON_POINTER_FORMAT`. Answers 204.
* `POST /v2/checkout/orders/{id}/confirm-payment-source` — Confirm payment source: sets a paypal or card source on an order
* `POST /v2/checkout/orders/{id}/authorize` — Authorize payment for order: this and `/capture` answer 201 and refuse with `ORDER_NOT_APPROVED`, `ACTION_DOES_NOT_MATCH_INTENT`, `ORDER_ALREADY_CAPTURED`, `ORDER_ALREADY_AUTHORIZED`, `PAYER_ACTION_REQUIRED`, or `DUPLICATE_INVOICE_ID` (another payment of the app already used the invoice id).
* `processing_instruction: ORDER_COMPLETE_ON_PAYMENT_APPROVAL` captures or authorizes on approval.

Statuses: `CREATED`, `PAYER_ACTION_REQUIRED`, `APPROVED`, `COMPLETED`, `VOIDED`. `SAVED` (vaulting with the order) and order tracking are not modeled.

Every write takes `PayPal-Request-Id`: a retry with the same id gets the first answer back for 72 hours, and a failed call is not kept, so it can be retried. `Prefer: return=minimal` (the default) answers `id`, `status`, and `links`; `return=representation` the whole resource. Links are HATEOAS links (`self`, `approve` or `payer-action`, `update`, `capture` or `authorize`).

## Hosted approval page

The `approve` or `payer-action` link opens `/checkoutnow?token={order id}`. It shows the order total and every seeded sandbox buyer. Picking one approves the order (shipping comes from the buyer's address unless the order has one or `shipping_preference` is `NO_SHIPPING`) and redirects to `return_url?token={order id}&PayerID={payer id}`. "Cancel" redirects to `cancel_url?token={order id}` and leaves the order as it was. For a card that needs 3D Secure, the page shows the authentication step instead, and the order's `payment_source.card.authentication_result` reports `liability_shift: POSSIBLE` afterwards. Without a `return_url`, the page says the payment is approved.

## Payments v2

* `GET /v2/payments/authorizations/{id}` — Show authorized payment details. `POST .../capture` captures all of an authorization or part of it (`amount`, `final_capture`), up to ten times, never more than is left (`MAX_CAPTURE_AMOUNT_EXCEEDED`). Statuses go `CREATED`, `PARTIALLY_CAPTURED`, `CAPTURED`.
* `POST .../reauthorize` is refused during the three day honor period (`REAUTHORIZATION_TOO_SOON`) and allows up to 115% of the original amount (at most 75 more in the currency); it returns a new authorization and voids the original. Authorizations expire after 29 days (`AUTHORIZATION_EXPIRED`).
* `POST .../void` answers 204 (or 200 with `Prefer: return=representation`). Voiding the last open authorization of an order with no captures voids the order.
* `GET /v2/payments/captures/{id}` — Show captured payment details. `POST .../refund` refunds it, with an optional `amount` (default: what is left). Refunds may be partial; more than is left answers `REFUND_AMOUNT_EXCEEDED`, a fully refunded capture `CAPTURE_FULLY_REFUNDED`, a capture older than 180 days `REFUND_TIME_LIMIT_EXCEEDED`. Capture statuses go `COMPLETED`, `PARTIALLY_REFUNDED`, `REFUNDED`.
* `GET /v2/payments/refunds/{id}` — Show refund details

Captures carry `seller_receivable_breakdown` with a PayPal fee of 3.49% plus a fixed fee per currency (0.49 USD); refunds carry `seller_payable_breakdown` with `total_refunded_amount`.

### Emulator clock

* `POST /_paypal/clock/advance` — Advance emulator clock: `{"seconds": 259200}`

It moves the emulator clock forward by `seconds`, past honor periods, authorization expiry, the refund window, and token expiry. The clock is saved with the state. SDKs time their cached token by the wall clock, so after jumping past nine hours create a new client to fetch a new token.

## Deterministic failures

* **Negative testing**: a `PayPal-Mock-Response: {"mock_application_codes":"INSTRUMENT_DECLINED"}` header makes the call fail with that issue and changes nothing, as in PayPal's sandbox. Most codes answer 422 `UNPROCESSABLE_ENTITY`; `INTERNAL_SERVER_ERROR` answers 500, `PERMISSION_DENIED` 403, `INVALID_RESOURCE_ID` 404, and parameter issues 400. `INSTRUMENT_DECLINED` on an order adds the `redirect` link back to the approval page.
* **Sandbox buyers**: a buyer seeded with `fail_with: INSTRUMENT_DECLINED` (or any issue) fails every authorize or capture. `INSTRUMENT_DECLINED` sends the order back to `CREATED` with a `redirect` link, so the buyer can pick another account. A buyer with `capture_status: PENDING` gets `PENDING` captures (`status_details.reason: PENDING_REVIEW`).
* **Card names**: a card whose `name` is one of PayPal's test triggers is declined: capturing gives a `DECLINED` capture and authorizing a `DENIED` authorization, with its processor response code: `CCREJECT-REFUSED` (0500), `CCREJECT-SF` (9500), `CCREJECT-EC` (5400), `CCREJECT-IRC` (5180), `CCREJECT-IF` (5120), `CCREJECT-LS` (9520), `CCREJECT-IA` (1330), `CCREJECT-BANK_ERROR` (5100), `CCREJECT-CVV_F` (00N7).
* **Fault injection**: the `faults:` seed key and the control API add latency or canned responses in front of any route, as for every Baseline service.

## Webhooks

Webhooks come from `apps[].webhooks` in the seed config or the webhooks API:

* `POST /v1/notifications/webhooks` — Create webhook: `url`, `event_types`, up to 10 per app
* `DELETE /v1/notifications/webhooks/{id}` — Delete webhook: the same path also takes `GET`, and `PATCH` to replace `/url` or `/event_types`
* `GET /v1/notifications/webhooks/{id}/event-types` — List event subscriptions for webhook
* `GET /v1/notifications/webhooks-event-types` — List available events
* `GET /v1/notifications/webhooks-events` — List event notifications: newest first, `page_size`, `start_time`, `end_time`, `event_type`, `transaction_id`, `next` links
* `POST /v1/notifications/simulate-event` — Simulate webhook event: sends a sample event of a type to a webhook or URL without keeping it
* `POST /v1/notifications/verify-webhook-signature` — Verify webhook signature

Errors use the v1 names: `VALIDATION_ERROR`, `INVALID_RESOURCE_ID`, `WEBHOOK_URL_ALREADY_EXISTS`, `WEBHOOK_NUMBER_LIMIT_EXCEEDED`. Plain `http://` URLs are accepted so local servers can listen, unlike PayPal.

Events sent: `CHECKOUT.ORDER.APPROVED`, `CHECKOUT.ORDER.COMPLETED`, `CHECKOUT.ORDER.VOIDED`, `PAYMENT.AUTHORIZATION.CREATED`, `PAYMENT.AUTHORIZATION.VOIDED`, `PAYMENT.CAPTURE.COMPLETED`, `PAYMENT.CAPTURE.PENDING`, `PAYMENT.CAPTURE.DECLINED`, `PAYMENT.CAPTURE.REFUNDED`. Each is an event (`id`, `event_version`, `create_time`, `resource_type`, `resource_version`, `event_type`, `summary`, `resource`, `links`) kept for the events list, `GET .../webhooks-events/{id}`, and `POST .../webhooks-events/{id}/resend`. Failed deliveries are retried 8 times with backoff.

Deliveries carry `PAYPAL-TRANSMISSION-ID`, `PAYPAL-TRANSMISSION-TIME`, `PAYPAL-TRANSMISSION-SIG`, `PAYPAL-CERT-URL`, and `PAYPAL-AUTH-ALGO: SHA256withRSA`. The signature is RSA SHA-256 over `transmission_id|transmission_time|webhook_id|crc32(raw body)`, with the CRC32 as an unsigned decimal. Verify it either way:

* The verify endpoint takes `auth_algo`, `cert_url`, `transmission_id`, `transmission_sig`, `transmission_time`, `webhook_id`, and `webhook_event` and answers `{"verification_status":"SUCCESS"}` or `FAILURE`.
* Offline, fetch the PEM certificate at `PAYPAL-CERT-URL` (served by the emulator at `/v1/notifications/certs/{id}`, one key per emulator, kept in saved state) and check the signature with it, as PayPal's own samples do:

```ts theme={null}
import { createVerify, X509Certificate } from "node:crypto";
import { crc32 } from "node:zlib";

const cert = new X509Certificate(await (await fetch(headers["paypal-cert-url"])).text());
const message = `${headers["paypal-transmission-id"]}|${headers["paypal-transmission-time"]}|${webhookId}|${crc32(rawBody) >>> 0}`;
const valid = createVerify("RSA-SHA256").update(message).verify(cert.publicKey, headers["paypal-transmission-sig"], "base64");
```

## Seed config

```json theme={null}
{
  "paypal": {
    "apps": [
      {
        "name": "My store",
        "client_id": "baseline-paypal-client-id",
        "client_secret": "baseline-paypal-client-secret",
        "brand_name": "Baseline Store",
        "webhooks": [
          {
            "url": "http://localhost:3000/api/paypal/webhook",
            "event_types": ["*"]
          }
        ]
      }
    ],
    "buyers": [
      {
        "email": "buyer@personal.example.com",
        "name": "John Doe"
      },
      {
        "email": "declined@personal.example.com",
        "name": "Dee Clined",
        "fail_with": "INSTRUMENT_DECLINED"
      },
      {
        "email": "review@personal.example.com",
        "name": "Rev Hold",
        "capture_status": "PENDING"
      }
    ]
  }
}
```

* `brand_name`: shown on the approval page
* `merchant_email`, `merchant_id`, and `scopes` are optional on an app

An app entry with an existing client id (such as the default one) changes only the fields it names. Buyers take `email`, `name` or `given_name` and `surname`, `payer_id` (13 characters from `2-9` and `A-Z` without `I` or `O`), `country_code`, `address`, `phone`, `fail_with`, and `capture_status`.

## Inspector

`/_inspector` shows orders, captures, authorizations, refunds, webhooks, events, deliveries, apps, sandbox buyers, and the emulator clock.

## Limits

* No order tracking, and no `SAVED` status or vaulting.
* No wallets or alternative payment methods beyond `paypal` and `card` payment sources.
* Subscriptions, Invoicing, Payouts, and Disputes are not modeled.

## Conformance

| | |
| - | - |
| Routes | 43 supported or partial, 2 planned |
| Route gate | fail |
| Spec gate | fail, 1 allowed ([paypal/paypal-rest-api-specifications](https://github.com/paypal/paypal-rest-api-specifications) at `90e8041`) |
| SDKs tested | `@paypal/paypal-server-sdk@2.5.0` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.