Documentation

PayPal

Connect PayPal for one-shot orders, subscriptions, and webhook event processing.

PayPal is the alternate payment processor in AppEngine, supported alongside Stripe via the same Upstream/Connect abstraction. The integration covers one-shot order capture, billing-agreement (subscription) creation, and webhook event processing. PayPal does not support marketplace splits in the same way Stripe Connect does — for that, use Stripe.

Connect PayPal

PayPal uses API credentials (client ID and secret) rather than full OAuth for server integrations. The admin UI form posts these to:

POST/upstream/save-integrationJWT
await fetch('/api/upstream/save-integration', {
  method: 'POST',
  headers: { orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
  body: JSON.stringify({
    type: 'paypal-provider',
    name: 'PayPal Production',
    credentials: {
      clientId: 'AY...',
      clientSecret: 'EM...',
      webhookId: '5LK...',
    },
    publicConfig: { mode: 'live', currency: 'USD' },
  }),
});

mode: 'sandbox' for test credentials. Get all three values from the PayPal Developer dashboard — webhookId is the ID PayPal assigns when you register a webhook URL pointing at AppEngine's Connect endpoint.

Storefront integration

The storefront uses PayPal's JavaScript SDK on the client and AppEngine on the server.

GET/storefront/paypal/configNo auth

Returns the clientId and mode for the SDK init:

const { clientId, mode } = await fetch('/api/storefront/paypal/config', {
  headers: { orgid: ORG_ID },
}).then(r => r.json());

const script = document.createElement('script');
script.src = `https://www.paypal.com/sdk/js?client-id=${clientId}&currency=USD`;
document.head.appendChild(script);

The storefront's generic payment endpoint accepts PayPal as a provider:

POST/storefront/take-paymentNo auth

Body:

{
  "provider": "paypal",
  "cart": { ... },
  "customer": { "email": "[email protected]" },
  "paymentMethod": { "orderId": "PAYPAL_ORDER_ID" }
}

The flow: client renders PayPal Buttons, customer approves on PayPal's UI, the SDK returns a PayPal orderId, the storefront calls take-payment with that ID, the server captures via the PayPal API and writes the AppEngine order.

Webhooks

POST/connect/webhook/paypalNo auth

PayPal POSTs events here. The Connect module verifies the signature using the webhook ID from the integration's credentials, then dispatches:

  • PAYMENT.CAPTURE.COMPLETED → mark order paid
  • PAYMENT.CAPTURE.DENIED → mark order failed
  • PAYMENT.CAPTURE.REFUNDED → reverse the order
  • BILLING.SUBSCRIPTION.ACTIVATED → activate subscription record
  • BILLING.SUBSCRIPTION.CANCELLED → cancel subscription record
  • BILLING.SUBSCRIPTION.PAYMENT.FAILED → mark subscription past-due

Application code subscribes via the event emitter (paypal:PAYMENT.CAPTURE.COMPLETED).

Subscriptions

PayPal subscriptions go through "billing agreements". Create a plan via the PayPal dashboard or the API, then start a subscription:

await fetch('/api/upstream/call/paypal-provider/create-subscription', {
  method: 'POST',
  headers: { orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
  body: JSON.stringify({
    planId: 'P-XXX',
    customerId: 'cust-abc',
    returnUrl: 'https://shop.example/sub-success',
    cancelUrl: 'https://shop.example/sub-cancel',
  }),
});
// { approvalUrl: '...' }

The browser redirects to approvalUrl; PayPal handles the consent and posts back via webhook with BILLING.SUBSCRIPTION.ACTIVATED.

Refunds

Refunds reuse the storefront refund endpoint:

POST/storefront/order/refund/:orderNumberJWT

The platform looks up the original capture ID from the order's payment metadata and calls PayPal's refund API.

Verifying a payment

GET/storefront/verify-payment/:provider/:configId/:paymentIdNo auth

Use provider: paypal and paymentId: <PayPal order ID>. Same fallback pattern as Stripe — used when webhook delivery is delayed and the customer is sitting on a "thank you" page.

Sandbox testing

PayPal sandbox accounts are completely separate from live. To test:

  1. Create a sandbox app in the PayPal Developer dashboard.
  2. Save the sandbox credentials as a separate integration (name: "PayPal Sandbox", publicConfig: { mode: "sandbox" }).
  3. Use sandbox buyer accounts (also in the dashboard) to approve test payments.

The storefront's paypal/config endpoint reads publicConfig.mode and the SDK loads the correct script.

PayPal currency support is per-account — most accounts are restricted to USD by default. If your storefront is multi-currency, verify each currency is enabled in the PayPal account settings before adding it as a checkout option.

Common quirks

  • Webhook signature failures — usually wrong webhook ID or wrong mode (sandbox webhook events arriving at a live integration).
  • Duplicate captures — PayPal sometimes sends the same PAYMENT.CAPTURE.COMPLETED event twice. The platform deduplicates by capture ID, so duplicate webhooks are safe.
  • Refund eligibility — captures older than 180 days cannot be refunded via the API. The platform surfaces this error from PayPal directly.