Back to 0fee
0fee

PayPal Integration: Orders API and Redirect Flow

How 0fee.dev integrates PayPal Orders API v2 for global wallet payments with redirect flow, capture on return, and webhook handling.

Thales & Claude | March 25, 2026 9 min 0fee
paypalorders-apiredirect-flowglobal-payments

PayPal covers over 200 countries and 25 currencies. It is the second global provider in 0fee's routing table, sitting alongside Stripe as a gateway that handles payments outside of Africa's mobile money ecosystem. While Stripe handles card payments, PayPal handles wallet payments -- customers who prefer to pay from their PayPal balance, linked bank account, or stored cards through PayPal's interface.

This article covers how we integrated PayPal's Orders API v2, the redirect-based payment flow, capture on return, and webhook handling for asynchronous status updates.

Why PayPal Alongside Stripe

The two providers serve different customer segments:

ProviderPrimary Use CaseCustomer ActionSettlement
StripeCredit/debit cardsEnter card detailsImmediate
PayPalPayPal wallet, bank accountsLog in to PayPalImmediate to 3 days

Some customers do not have credit cards but maintain a PayPal balance funded through bank transfers. Others prefer PayPal's buyer protection. For a payment orchestrator aiming to maximize conversion, offering both options is not a luxury -- it is a requirement.

PayPal Orders API v2

PayPal's Orders API v2 is a two-step process: create the order, then capture the funds after the customer approves. This is fundamentally different from Stripe's one-step Checkout Sessions approach.

Step 1: Create the Order

pythonclass PayPalProvider(BasePayinProvider):
    """PayPal provider for global wallet payments."""

    PROVIDER_ID = "paypal"
    SUPPORTED_METHODS = ["PAYIN_PAYPAL", "PAYIN_CARD"]

    def __init__(self, credentials: dict, environment: str = "sandbox"):
        self.client_id = credentials["client_id"]
        self.client_secret = credentials["client_secret"]
        self.base_url = (
            "https://api-m.paypal.com"
            if environment == "live"
            else "https://api-m.sandbox.paypal.com"
        )

    async def _get_access_token(self) -> str:
        """Obtain OAuth2 access token from PayPal."""
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.base_url}/v1/oauth2/token",
                data={"grant_type": "client_credentials"},
                auth=(self.client_id, self.client_secret),
                headers={"Content-Type": "application/x-www-form-urlencoded"},
            )
            return response.json()["access_token"]

    async def initiate_payment(self, data: dict) -> dict:
        token = await self._get_access_token()
        callback_url = f"{BASE_URL}/v1/payments/{data['transaction_id']}/return"

        order_payload = {
            "intent": "CAPTURE",
            "purchase_units": [{
                "reference_id": data["reference"],
                "amount": {
                    "currency_code": data["currency"],
                    "value": str(data["amount"]),
                },
                "description": f"Payment {data['reference']}",
            }],
            "payment_source": {
                "paypal": {
                    "experience_context": {
                        "return_url": callback_url,
                        "cancel_url": callback_url + "?cancelled=true",
                        "brand_name": "0fee.dev",
                        "landing_page": "LOGIN",
                        "user_action": "PAY_NOW",
                    }
                }
            },
        }

        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.base_url}/v2/checkout/orders",
                json=order_payload,
                headers={
                    "Authorization": f"Bearer {token}",
                    "Content-Type": "application/json",
                },
            )

        order = response.json()
        approve_url = next(
            link["href"] for link in order["links"]
            if link["rel"] == "payer-action"
        )

        return {
            "status": "pending",
            "provider_reference": order["id"],
            "redirect_url": approve_url,
            "payment_flow": {"type": "redirect"},
        }

The key difference from Stripe: PayPal requires an OAuth2 access token for every API call. We request a new token using client credentials before creating the order. The token has a limited lifetime (typically 8 hours), but we chose simplicity over caching -- each payment creates a fresh token. For a payment orchestrator handling moderate volumes, this adds negligible latency.

The Order Payload Structure

PayPal's Orders API uses a nested structure that requires careful formatting:

json{
    "intent": "CAPTURE",
    "purchase_units": [{
        "reference_id": "REF_001",
        "amount": {
            "currency_code": "USD",
            "value": "50.00"
        }
    }],
    "payment_source": {
        "paypal": {
            "experience_context": {
                "return_url": "https://0fee.dev/v1/payments/txn_xxx/return",
                "cancel_url": "https://0fee.dev/v1/payments/txn_xxx/return?cancelled=true",
                "brand_name": "0fee.dev",
                "landing_page": "LOGIN",
                "user_action": "PAY_NOW"
            }
        }
    }
}

Several fields deserve explanation:

FieldValuePurpose
intentCAPTURECapture funds immediately after approval
valueString "50.00"PayPal expects amounts as strings, not integers
landing_pageLOGINShow PayPal login page (vs. guest checkout)
user_actionPAY_NOWShow "Pay Now" button (vs. "Continue")
brand_name0fee.devDisplayed on PayPal's approval page

Note that PayPal accepts amounts as decimal strings ("50.00"), not as integer cents like Stripe. This means we do not need the zero-decimal currency conversion that Stripe requires. The amount from our database maps directly to PayPal's format with a simple str() conversion.

The Redirect Flow

PayPal uses a redirect-based approval flow. After creating the order, we extract the payer-action link from the response -- this is the URL where the customer logs into PayPal and approves the payment.

1. Developer: POST /v1/payments
   -> 0fee creates PayPal order
   -> Returns approval URL

2. Customer: Redirected to PayPal
   -> Logs in to PayPal account
   -> Reviews payment details
   -> Clicks "Pay Now"

3. PayPal: Redirects to our callback URL
   -> Includes token and PayerID in query params

4. 0fee: Captures the order
   -> Verifies and captures funds
   -> Updates transaction status
   -> Redirects to developer's success_url

The critical step is number 4: capture. Unlike Stripe where funds are captured automatically when the Checkout Session completes, PayPal requires an explicit capture call after the customer approves.

Capture on Return

When the customer approves the payment and PayPal redirects them to our callback URL, we must capture the order before the funds are transferred:

pythonasync def verify_paypal_payment(
    transaction_id: str, order_id: str
) -> bool:
    """Verify and capture PayPal payment."""
    token = await _get_paypal_access_token(transaction_id)

    async with httpx.AsyncClient() as client:
        # First, check the order status
        order_response = await client.get(
            f"{PAYPAL_API_URL}/v2/checkout/orders/{order_id}",
            headers={"Authorization": f"Bearer {token}"},
        )
        order = order_response.json()

        if order["status"] == "APPROVED":
            # Customer approved -- capture the funds
            capture_response = await client.post(
                f"{PAYPAL_API_URL}/v2/checkout/orders/{order_id}/capture",
                headers={
                    "Authorization": f"Bearer {token}",
                    "Content-Type": "application/json",
                },
            )
            capture = capture_response.json()

            if capture["status"] == "COMPLETED":
                await update_transaction_status(
                    transaction_id, "completed"
                )
                return True

        elif order["status"] == "COMPLETED":
            # Already captured (e.g., via webhook)
            await update_transaction_status(
                transaction_id, "completed"
            )
            return True

    return False

The two-step (create + capture) flow introduces a potential race condition: the webhook might fire before the customer's browser reaches our callback. We handle this by checking for both APPROVED (needs capture) and COMPLETED (already captured via webhook) statuses.

PayPal Order States

PayPal orders move through a defined state machine:

CREATED -> APPROVED -> COMPLETED
              |
              +-> VOIDED (if cancelled or expired)
StateMeaning0fee Action
CREATEDOrder created, awaiting approvalDisplay redirect URL
APPROVEDCustomer approved, awaiting captureCapture funds
COMPLETEDFunds captured successfullyMark transaction complete
VOIDEDOrder cancelled or expiredMark transaction failed

We map these to our unified transaction states:

PayPal State0fee State
CREATEDpending
APPROVEDpending
COMPLETEDcompleted
VOIDEDfailed

Webhook Handling

PayPal sends webhooks for various events. The most important for payment processing:

pythonasync def handle_paypal_webhook(request: Request) -> dict:
    """Handle incoming PayPal webhook events."""
    body = await request.json()
    event_type = body.get("event_type")

    if event_type == "CHECKOUT.ORDER.APPROVED":
        order_id = body["resource"]["id"]
        # Auto-capture if not already captured
        await auto_capture_order(order_id)

    elif event_type == "PAYMENT.CAPTURE.COMPLETED":
        capture = body["resource"]
        order_id = capture["supplementary_data"]["related_ids"]["order_id"]
        transaction_id = await get_transaction_by_provider_ref(order_id)
        await update_transaction_status(transaction_id, "completed")
        await fire_developer_webhook(transaction_id, "payment.completed")

    elif event_type == "PAYMENT.CAPTURE.DENIED":
        capture = body["resource"]
        order_id = capture["supplementary_data"]["related_ids"]["order_id"]
        transaction_id = await get_transaction_by_provider_ref(order_id)
        await update_transaction_status(transaction_id, "failed")
        await fire_developer_webhook(transaction_id, "payment.failed")

    return {"status": "ok"}

PayPal webhook verification uses the webhook ID and the request headers to validate authenticity:

pythonasync def verify_paypal_webhook(request: Request, webhook_id: str) -> bool:
    """Verify PayPal webhook signature."""
    headers = {
        "auth_algo": request.headers.get("paypal-auth-algo"),
        "cert_url": request.headers.get("paypal-cert-url"),
        "transmission_id": request.headers.get("paypal-transmission-id"),
        "transmission_sig": request.headers.get("paypal-transmission-sig"),
        "transmission_time": request.headers.get("paypal-transmission-time"),
    }
    body = await request.body()

    verification_payload = {
        "auth_algo": headers["auth_algo"],
        "cert_url": headers["cert_url"],
        "transmission_id": headers["transmission_id"],
        "transmission_sig": headers["transmission_sig"],
        "transmission_time": headers["transmission_time"],
        "webhook_id": webhook_id,
        "webhook_event": json.loads(body),
    }

    token = await _get_paypal_access_token()
    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"{PAYPAL_API_URL}/v1/notifications/verify-webhook-signature",
            json=verification_payload,
            headers={"Authorization": f"Bearer {token}"},
        )

    return response.json().get("verification_status") == "SUCCESS"

Comparison: PayPal vs. Stripe Integration

Having built both, the differences are instructive:

AspectStripePayPal
AuthenticationAPI key in headerOAuth2 token per request
Amount formatInteger (cents)Decimal string ("50.00")
Payment flowCreate session -> auto-captureCreate order -> approve -> capture
Callback dataSession ID in metadataOrder ID in URL params
Webhook verificationHMAC signatureAPI-based verification
Zero-decimal currenciesMust handle explicitlyNo special handling needed
Session lifetime24 hours3 hours (order expiry)

PayPal's OAuth2 requirement adds complexity but is standard for open banking APIs. The explicit capture step adds safety -- you can inspect the approved order before taking the customer's money. The trade-off is a more complex callback handler that must handle both the capture and the post-capture redirect.

Currency Support

PayPal supports 25 currencies, a subset of Stripe's broader coverage:

CurrencyRegionNotes
USDGlobalPrimary
EUREuropePrimary
GBPUnited KingdomPrimary
CADCanada
AUDAustralia
JPYJapanZero-decimal in Stripe, normal in PayPal
BRLBrazil
MXNMexico
SGDSingapore
HKDHong Kong

Notably, PayPal does not support XOF, XAF, or other African currencies. This is precisely why 0fee exists -- PayPal handles the global wallet audience, while Hub2, PawaPay, and PaiementPro handle African mobile money. The routing engine selects the right provider based on the customer's country and payment method.

Testing in Sandbox

PayPal's sandbox environment uses separate credentials and a sandbox URL (api-m.sandbox.paypal.com). Test accounts are created through PayPal's developer portal, each with a simulated balance and payment methods.

In 0fee's sandbox mode, when a developer uses a sk_sand_* API key and the routing engine selects PayPal, we use the sandbox credentials configured for that app. The customer sees a PayPal login page (sandbox version) where they can log in with a test account and approve the payment.

What We Learned

PayPal taught us three things about building a multi-provider orchestrator:

  1. Each provider has its own amount format. Stripe uses integer cents, PayPal uses decimal strings, and mobile money providers often use plain integers. The adapter layer must normalize these silently.
  1. Two-step payment flows need careful state management. The gap between "approved" and "captured" is a window where things can go wrong -- network failures, webhook race conditions, customer browser closures. Defensive coding is mandatory.
  1. OAuth2 providers need token management. For simplicity we create a fresh token per request, but at scale you would cache tokens and refresh them proactively.

PayPal was built in Session 001 alongside the entire backend, and refined through later sessions as the middleman callback pattern was applied uniformly. It remains one of the two global gateway providers in 0fee's routing table, handling wallet payments for over 200 countries.


This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles