Authentication
Pdirect uses a two-tier authentication model. Your merchant
backend holds a long-lived merchant_secret and uses it once per
checkout to mint a short-lived session_token. Your client —
SDK or otherwise — only ever sees the session_token. This isolates
card-data flows from credential theft on the client and prevents
amount or currency tampering after the merchant has committed to a
price.
The model in one paragraph
Section titled “The model in one paragraph”Your merchant backend holds a long-lived merchant_secret and uses
it once per checkout to mint a short-lived session_token from the
gateway. Your mobile or web SDK never touches the merchant_secret —
it only receives the session_token and presents it as
Authorization: Bearer <session_token> on every payment call. The
session is bound to a specific amount, currency, customer reference,
and (optionally) device fingerprint. A new transaction needs a new
session.
┌──────────────────┐ 1. start checkout ┌──────────────────┐│ Mobile / Web │ ──────────────────────▶ │ Your merchant ││ Pdirect SDK │ │ backend │└──────────────────┘ └──────────────────┘ ▲ │ │ │ 2. POST /api/v1/internal/sessions/create │ │ Authorization: Bearer <merchant_secret> │ ▼ │ ┌──────────────────┐ │ 3. session_token (one-time) │ Pdirect Gateway │ │ ◀──────────────────────────────── │ /api/v1/internal │ │ └──────────────────┘ │ ▲ │ │ │ │ 4. /api/v1/payments/collect, /submit, ... │ 5. Authorization: Bearer <session_token> │ Idempotency-Key, X-Device-Fingerprint └────────────────────────────────────────────┘Three keys, three roles
Section titled “Three keys, three roles”| Key | Where it lives | What it does |
|---|---|---|
merchant_secret | Your merchant backend (env var) | Mints session tokens via POST /internal/sessions/create |
webhook_secret | Your merchant backend (env var) | Verifies the HMAC signature on inbound webhooks from the gateway. (Currently not yet enforced — see overview.) |
session_token | Your mobile / web SDK (per-checkout, ephemeral) | Authenticates payment-collect / submit calls |
merchant_secret and webhook_secret are shown exactly once when
generated or rotated. Argon2id (merchant_secret) and Fernet
encryption (webhook_secret) make the cleartext unrecoverable from
the database after that point.
Backend integration (server-to-server)
Section titled “Backend integration (server-to-server)”One call per checkout. Auth is HTTP Bearer with the value
<key_id>:<merchant_secret> — the colon separator lets the gateway
look up the key row by its public identifier before running the
(intentionally slow) Argon2id verify.
curl https://app.api.gtwy.pdirect.com/api/v1/internal/sessions/create \ -X POST \ -H "Authorization: Bearer mch_3f9a2e1b:sk_live_x8a3pqr2zv9b1n4wmd6t7k0c8jh5y2u" \ -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 }'import crypto from "node:crypto";
async function mintSession(order: { amount: string; currency: "usd" | "cdf" | "xof"; customerReference: string;}) { const res = await fetch( "https://app.api.gtwy.pdirect.com/api/v1/internal/sessions/create", { method: "POST", headers: { Authorization: `Bearer ${process.env.PDIRECT_MERCHANT_KEY_ID}:${process.env.PDIRECT_MERCHANT_SECRET}`, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID(), }, body: JSON.stringify({ amount: order.amount, currency: order.currency, customer_reference: order.customerReference, ttl_seconds: 1800, }), }, ); if (!res.ok) throw new Error(`mint failed: ${res.status}`); return res.json() as Promise<{ session_id: string; session_token: string; issued_at: string; expires_at: string; }>;}import osimport uuidimport httpx
async def mint_session(amount: str, currency: str, customer_reference: str) -> dict: key_id = os.environ["PDIRECT_MERCHANT_KEY_ID"] secret = os.environ["PDIRECT_MERCHANT_SECRET"] async with httpx.AsyncClient(timeout=10.0) as client: r = await client.post( "https://app.api.gtwy.pdirect.com/api/v1/internal/sessions/create", headers={ "Authorization": f"Bearer {key_id}:{secret}", "Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4()), }, json={ "amount": amount, "currency": currency, "customer_reference": customer_reference, "ttl_seconds": 1800, }, ) r.raise_for_status() return r.json()Response (returned exactly once):
{ "session_id": "607f1f77bcf86cd799439011", "session_token": "sess_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789", "issued_at": "2026-05-05T10:15:00Z", "expires_at": "2026-05-05T10:45:00Z"}Hand the session_token to your client over your normal
checkout-init API. The gateway will reject any attempt to reuse it
for a different amount, currency, or customer.
Client integration
Section titled “Client integration”The client presents the session_token as
Authorization: Bearer <session_token> and submits the
payment-method-specific fields. It does not re-send
amount, currency, or customer_reference — the gateway reads
those off the session.
Once your backend has minted a token and passed it to the client, the client calls the payment endpoints directly:
curl https://app.api.gtwy.pdirect.com/api/v1/payments/collect \ -X POST \ -H "Authorization: Bearer sess_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789" \ -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 at mint time. Sending them in this body is still accepted
(soft-checked against the session binding) but redundant — omit
them.
Once at app startup:
void main() { PdirectPay.init(const PdirectPayConfig( environment: PdirectPayEnvironment.production, defaultLocale: 'fr', )); runApp(const MyApp());}Per checkout:
PdirectPayCheckout( configs: PdirectPayConfigs( sessionToken: sessionTokenFromYourBackend, acceptLanguage: 'fr', ), paymentBody: PdirectPaymentBody( // Auth-v3: amount, currency, and customerReference are bound to // the session_token at mint time — omit them from the body. 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) { /* ... */ }, onError: (e) { /* ... */ },);The SDK auto-attaches Idempotency-Key (UUID v4 per POST) and
X-Device-Fingerprint (SHA-256 of stable device attributes). You
don’t need to wire these manually.
import { Component } from "@angular/core";import { PdirectPayCheckoutComponent, PdirectPayConfig, PdirectPaymentBody, PdirectPayOnResponse, PdirectPayOnError,} from "@mms/pdirect-pay";
@Component({ selector: "app-checkout", standalone: true, imports: [PdirectPayCheckoutComponent], template: ` <pdirect-pay-checkout [configs]="configs" [paymentBody]="paymentBody" (response)="onResponse($event)" (error)="onError($event)" /> `,})export class CheckoutComponent { // Auth-v3: `token` carries the session_token your backend minted via // POST /api/v1/internal/sessions/create. The SDK sends it as // Authorization: Bearer <session_token>. configs: PdirectPayConfig = { token: sessionTokenFromYourBackend, isProduction: true, acceptLanguage: "en", };
paymentBody: PdirectPaymentBody = { // amount, currency, customerReference are bound to the session — // the SDK will omit them when they're unset. 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) { /* ... */ } onError(e: PdirectPayOnError) { /* ... */ }}What the gateway enforces
Section titled “What the gateway enforces”- Body rebinding —
amount,currency, andcustomer_referenceare bound to the session at mint time. The gateway reads them directly from the session row, so the SDK does not need to re-send them. These fields remain accepted in the/payments/collectbody for backwards compatibility (soft-checked against the session — drift returns a400witherror_code: "1101","1102", or"1103"). - Device fingerprint binding — the first request on a session
locks the device hash; subsequent requests must match. Default
rollout mode is
warnfor 14 days before flipping toenforce. - Single-use sessions — once
/payments/submitsucceeds, the session is markedCONSUMED. Retries need a new session. - Idempotency-Key replay protection — same key + same body within
24 h replays the cached response; same key + different body returns
409. See Idempotency. - Status / revocation gates — both the session and the underlying
merchant_secretmust be inALLOWEDstatus with norevoked_atstamp. A revocation mid-flight returns401on the next request without waiting for TTL.
Migrating from app-key auth
Section titled “Migrating from app-key auth”The old flow was:
- The SDK sent
app-key: <merchant_secret>as a request header. - The body of
/payments/collectcarriedcustomer_reference,amount, andcurrencyas required fields.
The new flow is:
- Your merchant backend mints a
session_tokenserver-to-server by callingPOST /api/v1/internal/sessions/createwithAuthorization: Bearer <key_id>:<merchant_secret>and(amount, currency, customer_reference)in the body. - The SDK presents the returned
session_tokenasAuthorization: Bearer <session_token>. - The body of
/payments/collectomitsamount,currency, andcustomer_reference— the gateway reads them from the session row.
The old contract still works: the gateway accepts the three legacy fields in the body when present and soft-checks them against the session binding. This makes the migration delta-friendly — bump your SDK at your own pace.
Migration checklist:
- Generate a
merchant_secret(andwebhook_secretonce webhook signing ships) in the dashboard. - Paste the cleartext + identifier into your merchant backend’s
environment:
PDIRECT_MERCHANT_KEY_IDandPDIRECT_MERCHANT_SECRET. - Update your “checkout init” endpoint to call
/api/v1/internal/sessions/createinstead of returning a long-livedapp_key. - Hand the returned
session_tokento your client. - Bump your mobile app to
pdirect_pay 3.0.0and renametoken:tosessionToken:onPdirectPayConfigs. The legacy field still works in 3.x with a deprecation warning; it’s removed in 4.0. - Drop
customerReference,amount, andcurrencyfromPdirectPaymentBody(Flutter) /PdirectPaymentBody(Angular) — the SDK omits them on the wire when unset.
See also
Section titled “See also”- Environments — sandbox / staging / production credentials
- Idempotency — retry semantics
- Errors — auth-related error codes (
1001–1005) - API reference: Sessions — full schema