Skip to main content
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 (2.5.0).

At a glance

Start

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 ([email protected]). @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:
Plain HTTP clients and other SDKs only need the base URL changed.

URL mapping

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

Seed config

  • 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

See CONFORMANCE.md for every provider.