Skip to content

PayPal Sandbox Runbook

Marcus Richards edited this page Oct 7, 2026 · 2 revisions

PayPal Sandbox Runbook (summary)

Procedure only. Source: docs/sandbox-runbook.md and settlement/sandbox_client.py at 9900817. Nothing here was executed for this wiki. Running anything against the sandbox is a maintainer decision.

Prerequisites

  • Environment variables (never commit, never print):
    • PERMIT_PAYPAL_CLIENT_ID, PERMIT_PAYPAL_CLIENT_SECRET: sandbox REST app credentials
    • PERMIT_PAYPAL_MERCHANT_ID: the sandbox merchant the allowlist is bound to. Without it, merchant binding is disabled and the server prints a warning
  • A browser and a sandbox buyer account for payer approval.
  • .env.sandbox is gitignored.
  • Start the service: python3 server.py --sandbox --port 8741.

Approval flow

  1. POST /api/permits: grant.
  2. POST /api/permits/<id>/spend → 202 approval_required with operation_id, order_id and approval_url. The reservation stays held. Do not spend again for the same purchase.
  3. The payer approves at approval_url. Confirm through the order's API state, never the checkout page (the page can stall while the order is already APPROVED).
  4. POST /api/operations/<operation_id>/resume authorizes the same order and registers the escrow.
  5. POST /api/escrows/<id>/release with evidence → capture (PayPal-Request-Id idempotent).
  6. On a timeout, the escrow is UNKNOWN → POST /api/escrows/<id>/reconcile.
  7. POST /api/permits/<id>/estop revokes and voids in-flight holds. Completed captures need a refund (out of scope).
  8. Record GET /api/ledger at the end. The receipt chain is the evidence.

Scripted beats (runbook)

Setup $50 → real $30 purchase captured → $60 blocked with zero new authorizations → e-stop mid-hold voids the authorization → tampered evidence refused → dropped capture response → UNKNOWN → reconcile with exactly one capture. The README says this integrated run is "not yet recorded".

Sandbox client facts (code at 9900817)

  • Orders API, intent: AUTHORIZE, currency CAD. Amounts are sent as cents/100 formatted to 2 decimals.
  • Trust binding on authorize: the payee merchant_id (if configured), the currency CAD, and the exact cents must match, or MerchantMismatch is raised. Binding runs against the POST authorize response.
  • A 422 ORDER_NOT_APPROVED raises NeedsPayerApproval. Any other non-200/201 response fails an assertion.
  • ⚠️ ORDER_ALREADY_AUTHORIZED recovery is absent. The recovery (read the existing authorization via GET order) and GET-based trust binding were added in ea2dcd3 after the 2026-10-05 sandbox run. They were removed in 980da3e, along with their 2 regression tests. On 9900817, a 422 ORDER_ALREADY_AUTHORIZED would raise an AssertionError in authorize_order. Whether PayPal still returns that code on this flow: Unknown (not exercised).
  • Every HTTP call has a 30 s timeout. wait_for_approval() defaults to a 120 s timeout and 5 s polling.

Helpers

The sandbox smoke script (scripts/sandbox_smoke.py) and the launch helper (launch_sandbox.py) are no longer in the tree (removed in 980da3e). The only sandbox entry point at 9900817 is server.py --sandbox.

See Evidence Map for what sandbox evidence exists.

Clone this wiki locally