Skip to content

PayPal

PayPal uses Orders v2 with capture: the library creates an order, you redirect the user to PayPal's approval page and, on return, the library captures the payment. The source of truth is the webhook, though the browser return also confirms the capture.

Configuration

ts
import { PayPalGateway } from '@kaizen/payments-gateway'

PayPalGateway.register({
  client_id: process.env.PAYPAL_CLIENT_ID!,
  client_secret: process.env.PAYPAL_CLIENT_SECRET!,
  webhook_id: process.env.PAYPAL_WEBHOOK_ID!,
  environment: 'sandbox',                         // 'sandbox' | 'production'
  public_base_url: process.env.PAYPAL_PUBLIC_BASE_URL!,
})
FieldTypeRequiredDescription
client_idstringClient ID of your PayPal REST app
client_secretstringClient Secret of your PayPal REST app
webhook_idstringWebhook ID, to verify the signature of notifications
environment'sandbox' | 'production'Selects the PayPal API endpoint
public_base_urlstringPublic URL of your API; PayPal returns the user to …/payments/paypal/return

Environment variables

You provide the values from your own environment and pass them to PayPalGateway.register():

ini
PAYPAL_CLIENT_ID=AeA1QIZ...
PAYPAL_CLIENT_SECRET=EGnHDxD...
PAYPAL_WEBHOOK_ID=8SB63863JN...
PAYPAL_ENVIRONMENT=sandbox           # 'sandbox' | 'production'
PAYPAL_PUBLIC_BASE_URL=https://your-domain.com
VariableFieldRequiredDescription
PAYPAL_CLIENT_IDclient_idREST app Client ID
PAYPAL_CLIENT_SECRETclient_secretREST app Client Secret
PAYPAL_WEBHOOK_IDwebhook_idWebhook ID to verify the signature
PAYPAL_ENVIRONMENTenvironmentsandbox or production (picks the endpoint)
PAYPAL_PUBLIC_BASE_URLpublic_base_urlPublic URL of your API for the browser return

Create a charge

bash
curl -X POST http://localhost:3000/payments/charge \
  -H 'content-type: application/json' \
  -d '{
    "gateway": "paypal",
    "amount": 4900,
    "currency": "EUR",
    "reference": "order-001",
    "redirect": {
      "success_url": "http://localhost:3000/ok",
      "cancel_url": "http://localhost:3000/ko"
    }
  }'
  • redirect.success_url and redirect.cancel_url are mandatory for PayPal. Without them → 400.
  • amount is in the currency's smallest unit (4900 = €49.00).
  • The response has a redirect_url with PayPal's approval page: send the user there.

The response has the same shape as every other gateway:

json
{
  "payment_id": "b9c1…",
  "gateway": "paypal",
  "status": "pending",
  "redirect_url": "https://www.sandbox.paypal.com/checkoutnow?token=…"
}

How the user pays

  1. Redirect the user to redirect_url (PayPal's approval page).
  2. The user approves the payment on PayPal.
  3. PayPal returns them to …/payments/paypal/return (a route the library itself mounts from your public_base_url). There the library captures the order and then redirects the browser to your success_url (or cancel_url if it wasn't captured).

TIP

You don't implement the return route: the library exposes it and PayPal uses it as the return_url. You only provide success_url / cancel_url in the charge body and public_base_url in the config.

Notification and confirmation

Besides the capture on the browser return, PayPal sends a webhook to POST /webhooks/paypal. The library:

  1. Verifies the signature by calling the PayPal API with your webhook_id. Invalid → 400.
  2. Reads the event_type:
    • PAYMENT.CAPTURE.COMPLETEDcaptured → emits payment.captured.
    • PAYMENT.CAPTURE.DENIEDfailed → emits payment.failed.

The gateway_ref that matches the notification to the Payment is the PayPal order id, generated by the library when creating the charge. Idempotency by (gateway, event_id) keeps the browser return and the webhook from being processed twice.