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:
| Provider | Primary Use Case | Customer Action | Settlement |
|---|---|---|---|
| Stripe | Credit/debit cards | Enter card details | Immediate |
| PayPal | PayPal wallet, bank accounts | Log in to PayPal | Immediate 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:
| Field | Value | Purpose |
|---|---|---|
intent | CAPTURE | Capture funds immediately after approval |
value | String "50.00" | PayPal expects amounts as strings, not integers |
landing_page | LOGIN | Show PayPal login page (vs. guest checkout) |
user_action | PAY_NOW | Show "Pay Now" button (vs. "Continue") |
brand_name | 0fee.dev | Displayed 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_urlThe 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 FalseThe 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)| State | Meaning | 0fee Action |
|---|---|---|
CREATED | Order created, awaiting approval | Display redirect URL |
APPROVED | Customer approved, awaiting capture | Capture funds |
COMPLETED | Funds captured successfully | Mark transaction complete |
VOIDED | Order cancelled or expired | Mark transaction failed |
We map these to our unified transaction states:
| PayPal State | 0fee State |
|---|---|
CREATED | pending |
APPROVED | pending |
COMPLETED | completed |
VOIDED | failed |
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:
| Aspect | Stripe | PayPal |
|---|---|---|
| Authentication | API key in header | OAuth2 token per request |
| Amount format | Integer (cents) | Decimal string ("50.00") |
| Payment flow | Create session -> auto-capture | Create order -> approve -> capture |
| Callback data | Session ID in metadata | Order ID in URL params |
| Webhook verification | HMAC signature | API-based verification |
| Zero-decimal currencies | Must handle explicitly | No special handling needed |
| Session lifetime | 24 hours | 3 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:
| Currency | Region | Notes |
|---|---|---|
| USD | Global | Primary |
| EUR | Europe | Primary |
| GBP | United Kingdom | Primary |
| CAD | Canada | |
| AUD | Australia | |
| JPY | Japan | Zero-decimal in Stripe, normal in PayPal |
| BRL | Brazil | |
| MXN | Mexico | |
| SGD | Singapore | |
| HKD | Hong 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:
- 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.
- 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.
- 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.