In payment processing, the moment between "the customer says they paid" and "we can confirm they actually paid" is where fraud lives. When a payment provider redirects a customer back to the merchant's website after checkout, that redirect proves nothing. Anyone can navigate to https://yourapp.com/payment/success?status=paid. Without server-side verification, a malicious user could skip payment entirely and land on the success page.
For 0fee.dev -- a payment orchestrator handling transactions on behalf of thousands of developers -- this is not an academic concern. If we redirect a customer to a developer's success URL without verifying the payment, the developer ships the product, and 0fee takes the blame. Session 023 was dedicated entirely to solving this problem across every provider.
The Problem: Unverified Redirects
Before Session 023, the payment flow had a security gap:
1. Developer: POST /v1/payments (with success_url: "https://shop.com/thanks")
2. 0fee: Creates payment with PaiementPro
3. PaiementPro: Redirects customer to https://shop.com/thanks?responsecode=0
4. Developer: Sees responsecode=0, assumes payment succeededThe problem at step 4: the responsecode=0 parameter in the URL can be spoofed. An attacker could simply visit https://shop.com/thanks?responsecode=0&ref=FAKE123 without ever paying. If the developer trusts the URL parameters without server-side verification, they have been defrauded.
This is not a PaiementPro-specific issue. Every redirect-based payment provider has the same vulnerability:
| Provider | Redirect Data | Spoofable? |
|---|---|---|
| PaiementPro | responsecode=0 in URL | Yes |
| Stripe | Session completed redirect | Yes (success URL is predictable) |
| PayPal | token= and PayerID= in URL | Harder, but possible |
| PawaPay | Return to success URL | Yes |
| BUI Wave | Return to success URL | Yes |
The Solution: Intercept, Verify, Redirect
The middleman callback pattern inserts 0fee between the payment provider and the developer's success URL:
1. Developer: POST /v1/payments (with success_url: "https://shop.com/thanks")
2. 0fee: Stores developer's success_url in payment_flow JSON
3. 0fee: Sends OUR callback URL to the provider
4. Provider: Redirects customer to 0fee callback, NOT developer URL
5. 0fee: Calls provider API to verify payment status
6. 0fee: If verified, updates transaction status
7. 0fee: Redirects customer to developer's success_urlThe developer never needs to verify the payment themselves. By the time the customer arrives at https://shop.com/thanks, 0fee has already confirmed the payment with the provider's API and fired a webhook to the developer's backend.
Storing Developer URLs
When a developer creates a payment, we store their callback URLs in the transaction's payment_flow JSON field:
pythonasync def initiate_payment(data: dict) -> dict:
"""Initiate payment and store developer URLs."""
# Store developer's URLs in payment_flow
payment_flow = {
"success_url": data.get("success_url", DEFAULT_SUCCESS_URL),
"cancel_url": data.get("cancel_url", DEFAULT_CANCEL_URL),
"error_url": data.get("error_url", DEFAULT_ERROR_URL),
}
# Generate our callback URL
callback_url = f"{BASE_URL}/v1/payments/{transaction_id}/return"
# Send OUR callback URL to the provider, not the developer's
provider_data = {
**data,
"success_url": callback_url,
"cancel_url": callback_url + "?cancelled=true",
"return_url": callback_url,
}
result = await provider.initiate_payment(provider_data)
# Save payment_flow with developer URLs
await db.execute(
"UPDATE transactions SET payment_flow = :flow WHERE id = :id",
{"flow": json.dumps(payment_flow), "id": transaction_id},
)
return resultThe key insight: the provider receives 0fee's callback URL, while the developer's URLs are stored safely in the database. The provider never sees the developer's URL, and the developer's URL never appears in the redirect chain until after verification.
The Callback Endpoint
The callback endpoint is the heart of the pattern. It receives the redirect from every provider and dispatches to the appropriate verification function:
python@router.get("/v1/payments/{transaction_id}/return")
async def payment_return_callback(
transaction_id: str,
request: Request,
):
"""Middleman callback -- verifies payment before redirecting to developer."""
# Get transaction and payment_flow
transaction = await get_transaction(transaction_id)
if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found")
payment_flow = json.loads(transaction["payment_flow"] or "{}")
provider_id = transaction["provider"]
provider_ref = transaction["provider_reference"]
# Check for cancellation
if request.query_params.get("cancelled"):
cancel_url = payment_flow.get("cancel_url", DEFAULT_CANCEL_URL)
return RedirectResponse(
f"{cancel_url}?transaction_id={transaction_id}&status=cancelled"
)
# Verify payment with the provider's API
verified = False
if provider_id == "paiementpro":
verified = await verify_paiementpro_payment(transaction_id, provider_ref)
elif provider_id == "stripe":
verified = await verify_stripe_payment(transaction_id, provider_ref)
elif provider_id == "pawapay" or provider_id == "pawapay_page":
verified = await verify_pawapay_payment(transaction_id, provider_ref)
elif provider_id == "bui":
verified = await verify_bui_payment(transaction_id, provider_ref)
elif provider_id == "paypal":
verified = await verify_paypal_payment(transaction_id, provider_ref)
# Redirect to developer's URL with status
if verified:
success_url = payment_flow.get("success_url", DEFAULT_SUCCESS_URL)
return RedirectResponse(
f"{success_url}?transaction_id={transaction_id}&status=completed"
)
else:
error_url = payment_flow.get("error_url", DEFAULT_ERROR_URL)
return RedirectResponse(
f"{error_url}?transaction_id={transaction_id}&status=failed"
)Provider-Specific Verification
Each provider has its own verification function because each provider has a different API for checking payment status.
PaiementPro: 3-Second Delay + 3 Retries
PaiementPro's redirect happens before their backend has fully processed the payment. Without a delay, the status check returns "pending" almost every time:
pythonasync def verify_paiementpro_payment(
transaction_id: str, invoice_number: str
) -> bool:
"""Verify PaiementPro payment with delay and retries.
PaiementPro redirects before processing completes.
We wait 3 seconds, then retry up to 3 times.
"""
# Initial delay -- PaiementPro needs processing time
await asyncio.sleep(3)
for attempt in range(3):
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.paiementpro.net/status/{invoice_number}",
headers={"Authorization": f"Bearer {api_key}"},
)
result = response.json()
response_code = result.get("responsecode")
if response_code == "0":
# Payment confirmed
await update_transaction_status(transaction_id, "completed")
await update_invoice_status(transaction_id, "paid")
return True
elif response_code in ("-1", "1"):
# Payment definitively failed
await update_transaction_status(transaction_id, "failed")
return False
# Still processing -- wait and retry
wait_time = 2 ** attempt # 1s, 2s, 4s
await asyncio.sleep(wait_time)
# After 3 retries, leave as pending (webhook will update later)
return FalseThe timing pattern:
| Step | Wait | Cumulative |
|---|---|---|
| Initial delay | 3s | 3s |
| First check | -- | 3s |
| Retry 1 wait | 1s | 4s |
| Second check | -- | 4s |
| Retry 2 wait | 2s | 6s |
| Third check | -- | 6s |
| Retry 3 wait | 4s | 10s |
| Final check | -- | 10s |
The customer waits at most 10 seconds on 0fee's callback page before being redirected. In practice, most PaiementPro payments confirm within the first or second check (3-4 seconds total).
Stripe: Session.retrieve
Stripe verification is clean and reliable. The Checkout Session ID is stored in the transaction metadata:
pythonasync def verify_stripe_payment(
transaction_id: str, session_id: str
) -> bool:
"""Verify Stripe payment by retrieving the Checkout Session."""
session = stripe.checkout.Session.retrieve(session_id)
if session.payment_status == "paid":
await update_transaction_status(transaction_id, "completed")
await update_invoice_status(transaction_id, "paid")
return True
return FalseNo retries needed. Stripe's session status is updated synchronously when the customer completes payment. By the time the redirect reaches our callback, the session is already marked as "paid."
PawaPay: Deposit Status
PawaPay uses a deposit status endpoint:
pythonasync def verify_pawapay_payment(
transaction_id: str, deposit_id: str
) -> bool:
"""Verify PawaPay payment via deposit status."""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{PAWAPAY_API_URL}/deposits/{deposit_id}",
headers={"Authorization": f"Bearer {api_key}"},
)
result = response.json()
if result.get("status") == "COMPLETED":
await update_transaction_status(transaction_id, "completed")
await update_invoice_status(transaction_id, "paid")
return True
return FalseBUI: Payment Status
BUI's verification follows the same pattern:
pythonasync def verify_bui_payment(
transaction_id: str, provider_ref: str
) -> bool:
"""Verify BUI payment via status API."""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BUI_API_URL}/v1/payments/{provider_ref}/status",
headers={"Authorization": f"Bearer {api_key}"},
)
result = response.json()
if result.get("status") in ("success", "completed"):
await update_transaction_status(transaction_id, "completed")
await update_invoice_status(transaction_id, "paid")
return True
return FalseImplementation Timeline
The middleman callback pattern was implemented in Session 023, applied to all four redirect-based providers in a single session:
| Provider | Verification Method | Session |
|---|---|---|
| PaiementPro | Status API polling (3s delay + 3 retries) | 023 |
| Stripe | Session.retrieve | 023 |
| PawaPay | Deposit status | 023 |
| BUI | Payment status | 023 |
Before Session 023, each provider sent the developer's URL directly to the payment provider. The session rewrote the payment flow for all four providers, stored developer URLs in the payment_flow JSON field, and created the unified callback endpoint.
Why Not Just Use Webhooks?
A reasonable question: if 0fee sends webhooks to developers when payments complete, why do we need the middleman callback at all?
The answer is timing. The redirect happens immediately -- the customer is sitting in front of their browser, waiting to see a success page. The webhook is asynchronous -- it might arrive seconds, minutes, or (in case of retries) hours later.
| Mechanism | When | Purpose |
|---|---|---|
| Middleman callback | Immediate (during redirect) | Real-time verification for UI |
| Webhook | Asynchronous (seconds to hours) | Backend system updates |
Both are necessary. The middleman callback provides instant feedback to the customer ("your payment was successful"). The webhook provides reliable backend notification ("update the order status in your database"). The developer can trust the redirect because 0fee has already verified it. They can also trust the webhook because it includes a signature they can verify.
The Customer Experience
From the customer's perspective, the middleman callback is invisible. They complete payment on the provider's page, see a brief loading indicator (0fee verifying), and land on the merchant's success page. The extra redirect adds less than a second for Stripe and PawaPay, and 3-10 seconds for PaiementPro (due to the processing delay).
Customer clicks "Pay" on provider page
|
v (Provider redirect to 0fee -- invisible)
0fee: "Verifying your payment..."
|
v (0fee redirect to developer -- invisible)
Developer's success page: "Thank you for your purchase!"The "verifying" step could display a loading animation, but in practice it is fast enough that most customers see only a momentary flash before arriving at the success page.
Edge Cases
Customer Closes Browser During Verification
If the customer closes their browser after the provider redirect but before 0fee's verification completes, the payment is still processed. The provider's webhook will fire asynchronously, and 0fee's webhook delivery system will notify the developer. The customer can check their payment status through the developer's app or by contacting support.
Provider API is Down During Verification
If the provider's status API is unreachable during verification, the callback returns false (unverified), and the customer is redirected to the error URL. However, the payment may have actually succeeded. The provider's webhook will eventually fire and update the transaction status. The developer should check the webhook for the definitive status.
Double-Click on Provider's Submit Button
Some payment providers protect against duplicate submissions, but not all. 0fee uses idempotency keys in the original payment creation to prevent duplicate transactions at the orchestrator level.
What We Learned
The middleman callback pattern taught us three things:
- Never trust client-side redirects for payment status. This applies universally -- not just to 0fee's specific providers. Any system that marks a payment as "completed" based on a URL parameter is vulnerable to fraud.
- Each provider has different verification timing. Stripe confirms instantly. PaiementPro needs a 3-second delay. PawaPay is usually fast but can lag. The verification layer must accommodate these differences without exposing them to the developer.
- The pattern scales to any number of providers. Adding a new redirect-based provider requires implementing a single
verify_{provider}_payment()function and adding a branch to the callback dispatcher. The callback URL, the developer URL storage, and the redirect logic are all reusable.
Session 023 was one of the most important sessions in 0fee's development. It closed a security gap that existed since Session 001, applying the fix uniformly across every redirect-based provider. The pattern is now mandatory for every new provider integration -- no payment redirect reaches a developer's URL without server-side verification.
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.