PayPal — payment gateway integration profile
Also known as: PayPal Checkout
PayPal REST
Orders v2
Category: wallet
Regions: Global
Countries: US
GB
EU
global
Currencies: USD
EUR
GBP
and 20+ other supported currencies
Payment methods: paypal
card

Authentication:
model: oauth-client-credentials
key_types: client_id
client_secret
access_token (Bearer, ~9h)
webhook_id
sandbox_instant: yes
kyc_required_for_live: yes


Payment initiation:
flow: redirect
endpoint: POST https://api-m.paypal.com/v2/checkout/orders
http_method: POST
required_params: intent
purchase_units[].amount.currency_code
purchase_units[].amount.value
returns: id
status
links[] (rel:payer-action | rel:approve)
amount_unit: decimal-2dp


Status handling:
mechanisms: webhook
poll
webhook_verification: Headers PAYPAL-TRANSMISSION-ID/-TIME/-SIG/-CERT-URL/-AUTH-ALGO. Offline: RSA-verify PAYPAL-TRANSMISSION-SIG (base64) over 'transmissionId|transmissionTime|webhookId|crc32(RAW body)' using the cert at PAYPAL-CERT-URL (paypal.com only, cache it). Or online postback POST /v1/notifications/verify-webhook-signature (needs webhook_id; unsupported for the simulator). RAW body required for crc32.
raw_status_values: CREATED
SAVED
APPROVED
PAYER_ACTION_REQUIRED
COMPLETED
VOIDED
PENDING
DECLINED
FAILED
REFUNDED
PARTIALLY_REFUNDED
status_map: CREATED: pending
SAVED: pending
APPROVED: pending
PAYER_ACTION_REQUIRED: pending
PENDING: pending
COMPLETED: paid
VOIDED: failed
DECLINED: failed
FAILED: failed
REFUNDED: refunded
PARTIALLY_REFUNDED: refunded
CUSTOMER.DISPUTE.CREATED: disputed

Capabilities:
redirect: yes
poll: yes
webhook: yes
mobile_push: no
refund: yes
recurring: yes

Credentials required:
key: clientId
label: Client ID
secret: no
note: PayPal Developer dashboard → Apps & Credentials → your app → Client ID
key: clientSecret
label: Client Secret
secret: yes
note: same screen as the Client ID; used for OAuth2 Basic auth
key: webhookId
label: Webhook ID
secret: no
note: required to verify inbound webhook signatures (per merchant/app)

Sandbox:
test_credentials: note: Sandbox client_id/secret are instant from developer.paypal.com → Apps & Credentials → Sandbox; live needs a verified Business account + KYC
test_cards: number: 4032039999999994
brand: visa
result: success (generated in Sandbox → Testing Tools → Credit Card Generator)
notes: Testing Tools → Sandbox Accounts gives a business (seller) and a personal (buyer w/ balance) account. Approve an order by logging into the personal account at sandbox.paypal.com/checkoutnow?token=<orderId>. The webhook simulator exists but online postback verify is NOT supported there — use offline crypto verification.

Known pain points and mitigations:
problem: Order marked paid on APPROVED — merchant ships but no money arrives
cause: For intent=CAPTURE, CHECKOUT.ORDER.APPROVED means the buyer approved but funds are NOT captured yet
mitigation: Map paid ONLY to a capture COMPLETED; auto-capture (POST /v2/checkout/orders/{id}/capture) on ORDER.APPROVED for CAPTURE intent, then fulfil on PAYMENT.CAPTURE.COMPLETED
severity: critical
problem: Webhook signature verification fails
cause: The crc32 is computed over a re-serialised JSON body instead of the raw bytes
mitigation: Capture the RAW request body before any JSON parser; build the message 'transmissionId|time|webhookId|crc32(rawBody)' and RSA-verify with the cached cert
severity: critical
problem: Access-token churn / intermittent 401s under load
cause: Requesting a new OAuth token on every call, or reusing an expired one
mitigation: Central token cache keyed by (env, client_id), single-flight refresh at ~80% of expires_in (token lasts ~9h)
severity: high
problem: verify-webhook-signature endpoint fails / needs webhook_id
cause: Online postback requires the merchant's webhook_id and does not work from the webhook simulator
mitigation: Store webhook_id per merchant; default to OFFLINE crypto verification which also works with simulated events
severity: high
problem: Duplicate orders / double captures on retry
cause: Network retries re-POST create-order or capture
mitigation: Send a stable PayPal-Request-Id (idempotency key) on create/capture/refund; dedupe inbound webhooks by event id
severity: high
problem: Sandbox creds hit the live host (or vice-versa) → 401
cause: Wrong base URL for the environment
mitigation: Select api-m.sandbox.paypal.com for test vs api-m.paypal.com for live from an env flag; validate creds with a token call on connect
severity: medium

SDKs:
node: @paypal/paypal-server-sdk
php: paypal/paypal-server-sdk
rest_direct_ok: yes

Notes: Zimbabwe PayPal accounts are historically RESTRICTED to send-only — a ZW merchant generally CANNOT receive/withdraw PayPal funds without an entity in a supported country. Verify merchant receive capability before going live; sandbox testing works fine regardless. Two-step flow: create order → buyer approves (redirect) → capture. APPROVED is never money; only a capture COMPLETED is settled funds.

Fields not yet cross-checked against official docs: exact supported-currency count
current ZW receive-capability status
webhook offline-signature verification against a real event