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:
/upstream/save-integrationJWTawait 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.
/storefront/paypal/configNo authReturns 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}¤cy=USD`;
document.head.appendChild(script);
The storefront's generic payment endpoint accepts PayPal as a provider:
/storefront/take-paymentNo authBody:
{
"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
/connect/webhook/paypalNo authPayPal 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 paidPAYMENT.CAPTURE.DENIED→ mark order failedPAYMENT.CAPTURE.REFUNDED→ reverse the orderBILLING.SUBSCRIPTION.ACTIVATED→ activate subscription recordBILLING.SUBSCRIPTION.CANCELLED→ cancel subscription recordBILLING.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:
/storefront/order/refund/:orderNumberJWTThe platform looks up the original capture ID from the order's payment metadata and calls PayPal's refund API.
Verifying a payment
/storefront/verify-payment/:provider/:configId/:paymentIdNo authUse 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:
- Create a sandbox app in the PayPal Developer dashboard.
- Save the sandbox credentials as a separate integration (
name: "PayPal Sandbox", publicConfig: { mode: "sandbox" }). - 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.COMPLETEDevent 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.