Collect payments
This guide walks the full collect flow: mint a session, collect, the
optional submit / OTP / 3DS legs, and reconcile via webhook or poll.
The same pattern handles every payment method — only the
payment_method and the method-specific fields change.
When to use what
Section titled “When to use what”| Situation | Use |
|---|---|
| One-shot card / wallet / mobile-money charge in a mobile app | Flutter SDK’s PdirectPayCheckout widget |
| Same flow in an Angular app | Angular SDK’s <pdirect-pay-checkout> |
| Server-driven hosted page | /payments/collect-redirect |
| Backend-only flow (offline orders, retries) | Direct HTTP with the API reference |
Flow at a glance
Section titled “Flow at a glance”1. backend POST /internal/sessions/create → session_token2. client POST /payments/collect → transaction_id, status: pending3. (optional) POST /payments/submit → status: processing or approved4. (optional) POST /payments/verify-otp → status: approved OR4. (3DS) redirect → 3DS → /card/3ds-callback → status: approved | declined5. webhook POST notify_url → final state OR5. poll GET /payments/status/{transaction_id}Steps 3 and 4 don’t apply to every method:
| Payment method | Need step 3? | Need step 4? |
|---|---|---|
wallet | Yes (PIN) | Sometimes (OTP) |
mobile_money | Yes (MSISDN) | No (push to mobile) |
card (HOSTED_SESSION) | Yes (session_id) | 3DS challenge if issuer requires |
flash | Yes (PIN) | Sometimes |
bank_transfer | Yes | No (manual proof upload) |
google_pay / apple_pay | No (token at /collect) | No |
Step 1 — Mint the session
Section titled “Step 1 — Mint the session”Server-side. The session binds (amount, currency, customer_reference).
curl https://app.api.gtwy.pdirect.com/api/v1/internal/sessions/create \ -X POST \ -H "Authorization: Bearer mch_3f9a2e1b:sk_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 7c2e9a1d-4b6f-4c8e-9a5f-1d3e2c7b9a4d" \ -d '{ "amount": "12.50", "currency": "usd", "customer_reference": "cust_abc123", "ttl_seconds": 1800 }'(Mint server-side; the Flutter SDK never sees merchant_secret.)
// Your backend, Node example.const r = await fetch(`${BASE}/api/v1/internal/sessions/create`, { method: "POST", headers: { Authorization: `Bearer ${KEY_ID}:${SECRET}`, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID(), }, body: JSON.stringify({ amount: "12.50", currency: "usd", customer_reference: "cust_abc123", ttl_seconds: 1800, }),});(Same as Flutter — your Angular app’s backend mints the session
server-side. The Angular SDK never sees merchant_secret.)
Step 2 — Collect
Section titled “Step 2 — Collect”curl https://app.api.gtwy.pdirect.com/api/v1/payments/collect \ -X POST \ -H "Authorization: Bearer sess_AbCdEf..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 8d3f0e2b-5c7d-4e9f-aab2-2e4f3d8c0b6e" \ -d '{ "payment_method": "wallet", "user_info": "full", "payment_method_info": "full", "fee_covered_by": "buyer", "delivery_behaviour": "direct_delivery", "notify_url": "https://merchant.example.com/pdirect/webhook", "first_name": "John", "last_name": "Doe", "email": "john.doe@example.com", "phone_number": "+243998857000", "wallet_number": "3005710720108954", "wallet_pin": "048698" }'amount, currency, and customer_reference are bound to the
session — omit them. They’re still accepted for backwards compat,
soft-checked against the binding; drift returns 400.
PdirectPayCheckout( configs: PdirectPayConfigs( sessionToken: sessionTokenFromBackend, acceptLanguage: 'en', ), paymentBody: const PdirectPaymentBody( // amount / currency / customerReference are bound to the session // — omit them. paymentMethod: 'wallet', notifyUrl: 'https://merchant.example.com/pdirect/webhook', firstName: 'John', lastName: 'Doe', email: 'john.doe@example.com', phoneNumber: '+243998857000', walletNumber: '3005710720108954', walletPin: '048698', ), onResponse: (r) => debugPrint('ok: ${r.transactionId}'), onError: (e) => debugPrint('err: ${e.errorCode?.name} ${e.message}'),)@Component({ template: `<pdirect-pay-checkout [configs]="configs" [paymentBody]="paymentBody" (response)="onResponse($event)" (error)="onError($event)" />`,})export class CheckoutComponent { // `token` holds the per-checkout session_token your backend minted. configs: PdirectPayConfig = { token: sessionTokenFromBackend, isProduction: true, acceptLanguage: "en", };
paymentBody: PdirectPaymentBody = { // amount / currency / customerReference are bound to the session // — leave unset and the SDK omits them on the wire. paymentMethod: "wallet", notifyUrl: "https://merchant.example.com/pdirect/webhook", firstName: "John", lastName: "Doe", email: "john.doe@example.com", phoneNumber: "+243998857000", walletNumber: "3005710720108954", walletPin: "048698", };
onResponse(r: PdirectPayOnResponse) { console.log(r.transactionId); } onError(e: PdirectPayOnError) { console.warn(e.errorCode, e.message); }}Response:
{ "success": true, "transaction_id": "txn_8f3a4c2e9b1d7a6f5c0e8d", "status": "pending", "message": "Payment request received", "amount": "12.50", "currency": "usd", "fee_amount": "0.50", "total_amount": "13.00", "created_at": "2026-05-05T10:15:00Z"}The SDK widgets handle steps 3–4 internally. If you’re driving the flow with cURL or direct service calls, continue below.
Step 3 — Submit (when needed)
Section titled “Step 3 — Submit (when needed)”For wallet, flash, mobile money, and card methods you’ll need to
submit the method-specific details after /collect:
curl https://app.api.gtwy.pdirect.com/api/v1/payments/submit \ -X POST \ -H "Authorization: Bearer sess_AbCdEf..." \ -H "Content-Type: application/json" \ -d '{ "payment_id": "pay_encrypted_abc123def456", "payment_method_id": "pm_wallet_001", "ewallet_number": "3005710720108954", "ewallet_pin": "048698" }'The payment_id (encrypted) comes from the /collect response.
After a successful /submit the session is CONSUMED — retries
need a new session.
Step 4 — OTP (when needed)
Section titled “Step 4 — OTP (when needed)”Some flows return status: "pending_otp_verification" after submit.
Prompt the customer for their six-digit code, then:
curl https://app.api.gtwy.pdirect.com/api/v1/payments/verify-otp \ -X POST \ -H "Authorization: Bearer sess_AbCdEf..." \ -H "Content-Type: application/json" \ -d '{ "payment_id": "pay_encrypted_abc123def456", "otp_code": "482915" }'5 / 5min rate limit on this endpoint — see
Handle OTP flows.
Step 5 — Reconcile
Section titled “Step 5 — Reconcile”Two options. Use webhooks in production; polling is fine for development.
Webhook
Section titled “Webhook”POST /pdirect/webhook HTTP/1.1Host: merchant.example.comContent-Type: application/json
{ "transaction_id": "txn_8f3a4c2e9b1d7a6f5c0e8d", "customer_reference": "cust_abc123", "status": "approved", "amount": "12.50", "currency": "usd", "completed_at": "2026-05-05T10:15:08Z"}See Webhooks overview.
curl https://app.api.gtwy.pdirect.com/api/v1/payments/status/txn_8f3a4c2e9b1d7a6f5c0e8d \ -H "Authorization: Bearer sess_AbCdEf..."Use the same session you minted in step 1; it’s still valid until
/submit succeeds (which marks it CONSUMED) or it expires.
Common failures
Section titled “Common failures”error_code | Meaning | Fix |
|---|---|---|
1101 INVALID_AMOUNT | Body’s amount doesn’t match session binding | Mint a new session |
1102 INVALID_CURRENCY | Body’s currency doesn’t match | Mint a new session |
1109 MISSING_REQUIRED_FIELD | A required field for the chosen payment_method is missing | See Payment methods |
1201 INSUFFICIENT_FUNDS | Customer can’t pay | Surface to customer |
1301 RATE_LIMIT_EXCEEDED | Too many requests | Honour Retry-After |
1502 PAYMENT_DECLINED | Processor rejected | Don’t auto-retry |
Full table at Errors → catalogue.
See also
Section titled “See also”- Card payments with 3DS
- Mobile money status polling
- Handle OTP flows
- Disburse payouts — the B2C inverse of this flow