Repository navigation
PayPal API
import { PayPal } from '@tundraconnect/paypal';
const client = new PayPal({
auth: {
type: 'CUSTOM',
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
environment: 'sandbox',
},
});Credentials go through the standard auth option — { type: 'CUSTOM', clientId, clientSecret, environment }, all four required:
auth field |
Notes |
|---|---|
clientId |
REST app Client ID, from the Developer Dashboard. |
clientSecret |
REST app Client Secret. Never logged, never placed in a thrown error's context. |
environment |
'sandbox' or 'live'. Selects the API host — see below. |
An omitted or malformed auth throws at construction time — see
Errors's CONFIG_* codes.
environment |
Base URL |
|---|---|
'sandbox' |
https://api-m.sandbox.paypal.com |
'live' |
https://api-m.paypal.com |
Unlike a vendor whose test/live modes share one host and differ only by a key
prefix (Stripe, Razorpay), PayPal's sandbox and live environments are
different hosts entirely — environment is what selects between them, for
both the token exchange and every API call.
clientId/clientSecret are never sent directly on an API call. Instead,
this connect exchanges them for a short-lived Bearer access token
(POST /v1/oauth2/token, Authorization: Basic base64(clientId:clientSecret),
grant_type=client_credentials) the first time it needs one, and reuses that
token — cached in memory — until it's within 60 seconds of PayPal's
documented expiry (typically ~9 hours), at which point the next call
transparently re-exchanges it. This is entirely automatic: no method call is
needed to trigger or refresh it.
Concurrent calls that all find a cold or expired cache share a single
in-flight exchange rather than each independently hitting the token
endpoint — see Errors for what happens if that exchange
fails (TOKEN_EXCHANGE_FAILED when the token endpoint refuses, the
transient TIMEOUT / NETWORK_ERROR when it can't be reached).
| Method | Endpoint | Result |
|---|---|---|
createOrder(request) |
POST /v2/checkout/orders |
The created order |
getOrder(orderId) |
GET /v2/checkout/orders/{id} |
The order's current state |
captureOrder(orderId) |
POST /v2/checkout/orders/{id}/capture |
The order, with captures populated |
refundCapture(captureId, request?) |
POST /v2/payments/captures/{id}/refund |
The refund |
const order = await client.createOrder({
intent: 'CAPTURE',
purchase_units: [
{ amount: { currency_code: 'USD', value: '10.00' } },
],
});
const current = await client.getOrder(order.id);
const captured = await client.captureOrder(order.id);
const capture = captured.purchase_units[0]?.payments?.captures?.[0];
await client.refundCapture(capture!.id, {
amount: { currency_code: 'USD', value: '5.00' },
note_to_payer: 'Partial refund',
});-
request.intent—'CAPTURE'or'AUTHORIZE'(required). Only'CAPTURE'-intent orders can be passed tocaptureOrderdirectly — authorizing a payment for later capture (POST .../authorize) is a separate PayPal endpoint this connect does not implement. -
request.purchase_units— 1-10 entries, each requiringamount: { currency_code, value }.valueis a decimal STRING (e.g."10.00"), never a number — see Schemas. Optional per-unit fields:reference_id,items,payee,custom_id,invoice_id,description, andamount.breakdown(item_total,tax_total,shipping,handling,discount,insurance,shipping_discount). -
request.application_context— optional payer-experience customization:brand_name,return_url,cancel_url,user_action('CONTINUE'/'PAY_NOW'). PayPal's docs mark this object deprecated in favor ofpayment_source.paypal.experience_context, but it remains functional and is what this connect models;payment_sourceitself (a large payment-method discriminated union — card, PayPal wallet, Venmo, BLIK, iDEAL, ...) is out of scope for v1. - Response: the created order —
id,status('CREATED'/'SAVED'/'APPROVED'/'VOIDED'/'COMPLETED'/'PAYER_ACTION_REQUIRED'),links(including therel: "approve"link to redirect the payer to),purchase_units.
- No body. Returns the same order shape as
createOrder, reflecting the order's current status — poll this (or handle PayPal's return-URL redirect) to detect when a payer has approved an order.
- Sent with an empty JSON body — PayPal's optional
payment_sourcecapture override is out of v1 scope, for the same reason it's out of scope oncreateOrder. - Response: the order, with
statustypically'COMPLETED'andpurchase_units[].payments.captures[]populated — read a capture'sidoff there to pass torefundCapture.
-
request.amount— optional; omit for a full refund of the capture. -
request.note_to_payer— optional reason shown to the payer. - Response:
{ id, status, amount, ... }—statusis one of'CANCELLED'/'FAILED'/'PENDING'/'COMPLETED'.
- Webhook signature verification — PayPal's is unusually complex (dual local-certificate or API-callback verification) and is not implemented. Verify webhooks against PayPal's own guidance until this connect adds it.
-
payment_source— the full payment-method union (cards, wallets, BNPL, local payment methods) on order creation/capture. -
authorizeOrder(AUTHORIZE-intent orders' separate capture flow).
See Errors for failure handling and Schemas for request/response validation.
Verifies an inbound webhook from PayPal — a method on the client, not an HTTP call (except that PayPal verifies server-side, see below).
Scheme (the five PayPal-Transmission-* / PayPal-Cert-Url / PayPal-Auth-Algo headers): PayPal verifies its own signature server-side: the method posts the transmission headers, your webhook_id and the event body exactly as delivered to POST /v1/notifications/verify-webhook-signature (one authenticated call per webhook, using this client's OAuth token) and reads verification_status.
| Option | Type | Required | Description |
|---|---|---|---|
payload |
string |
yes | Raw body exactly as received. |
headers |
Headers | object |
yes | Case-insensitive lookup. |
webhookId |
string |
yes | The webhook's id from the developer dashboard. |
Returns: The parsed event (unknown) when PayPal answers SUCCESS.
Throws: PayPalError with WEBHOOK_INVALID_HEADERS, WEBHOOK_SIGNATURE_INVALID, RESPONSE_ERROR, plus the usual vendor codes for the verification call.
const raw = await req.text(); // text(), never json()
const event = await client.verifyWebhook({
payload: raw,
headers: req.headers,
webhookId: PAYPAL_WEBHOOK_ID,
});Comparison is constant-time via @tundralibs/crypt. Treat WEBHOOK_SIGNATURE_INVALID as a forged request.
createOrder, captureOrder and refundCapture accept a trailing IdempotentRequestOptions:
const key = PayPal.newIdempotencyKey(); // a ULID from @tundralibs/id
await db.orders.update(id, { idempotencyKey: key }); // persist FIRST
await client.createOrder(request, { idempotencyKey: key });Sent as PayPal-Request-Id (≤ 108 chars). Supply the same key on every retry of one logical operation and PayPal returns the original result instead of acting again — the defence against a double charge when a request times out after the vendor already acted. Deliberately opt-in: a fresh key per call would be indistinguishable from none, and a fresh key per retry would defeat the point.
- Algolia
- AzureBlob
- CloudflareDNS
- CloudflareEmail
- CloudflareSaaS
- CloudflareTurnstile
- CoinGecko
- Discord
- DodoPayments
- GCS
- GoogleAnalytics
- GoogleCertificateManager
- GoogleWebRisk
- Kalshi
- ntfy
- OpenExchange
- OpenWeatherMap
- PayPal
- Polymarket
- Razorpay
- Resend
- S3
- SendGrid
- Sentry
- Slack
- Stripe
- Telegram
- Twilio
- UpstashRedis
- URLhaus