Code samples
A charge, end to end: create it, wait for the answer, and verify the webhook that brings it. Three languages, the same shape. Each sample reads the key and the base URL from the environment, which is the whole of going live.
Create a charge and wait
js
const BASE = process.env.PAMOPAY_BASE; // …/merchantSandboxApi, then …/merchantApi
const KEY = process.env.PAMOPAY_KEY; // sk_test_…, then sk_live_…
async function call(method, path, { body, idempotencyKey } = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (!res.ok) {
const err = new Error(`${json.error.code} (${res.status}) — ${json.error.doc}`);
err.code = json.error.code;
err.retryable = res.status === 503 || res.status === 429;
err.requestId = res.headers.get("PamoPay-Request-Id");
throw err;
}
return json;
}
export async function chargeOrder(order) {
const charge = await call("POST", "/v1/charges", {
idempotencyKey: `order-${order.id}`,
body: { amount_minor: order.totalMinor, rail: order.rail, msisdn: order.phone, reference: order.id },
});
// Fulfil on the webhook. Polling is the fallback, not the plan.
for (let i = 0; i < 30; i++) {
const now = await call("GET", `/v1/charges/${charge.id}`);
if (now.state !== "processing" && now.state !== "requires_payment") return now;
await new Promise((r) => setTimeout(r, 5000));
}
return charge; // still processing after two and a half minutes: leave the order open
}python
import os, time, requests
BASE = os.environ["PAMOPAY_BASE"]
KEY = os.environ["PAMOPAY_KEY"]
class PamoPayError(Exception):
def __init__(self, status, body, request_id):
super().__init__(f"{body['error']['code']} ({status}) — {body['error']['doc']}")
self.code = body["error"]["code"]
self.retryable = status in (429, 503)
self.request_id = request_id
def call(method, path, body=None, idempotency_key=None):
headers = {"Authorization": f"Bearer {KEY}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
res = requests.request(method, BASE + path, json=body, headers=headers, timeout=30)
if not res.ok:
raise PamoPayError(res.status_code, res.json(), res.headers.get("PamoPay-Request-Id"))
return res.json()
def charge_order(order):
charge = call("POST", "/v1/charges", idempotency_key=f"order-{order['id']}", body={
"amount_minor": order["total_minor"], "rail": order["rail"],
"msisdn": order["phone"], "reference": order["id"],
})
for _ in range(30):
now = call("GET", f"/v1/charges/{charge['id']}")
if now["state"] not in ("processing", "requires_payment"):
return now
time.sleep(5)
return chargephp
<?php
final class PamoPay {
public function __construct(private string $base, private string $key) {}
public function call(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array {
$headers = ["Authorization: Bearer {$this->key}", "Content-Type: application/json"];
if ($idempotencyKey !== null) $headers[] = "Idempotency-Key: {$idempotencyKey}";
$ch = curl_init($this->base . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException("{$json['error']['code']} ({$status}) — {$json['error']['doc']}", $status);
}
return $json;
}
public function chargeOrder(array $order): array {
$charge = $this->call("POST", "/v1/charges", [
"amount_minor" => $order["total_minor"], "rail" => $order["rail"],
"msisdn" => $order["phone"], "reference" => $order["id"],
], "order-{$order['id']}");
for ($i = 0; $i < 30; $i++) {
$now = $this->call("GET", "/v1/charges/{$charge['id']}");
if (!in_array($now["state"], ["processing", "requires_payment"], true)) return $now;
sleep(5);
}
return $charge;
}
}
$pamopay = new PamoPay(getenv("PAMOPAY_BASE"), getenv("PAMOPAY_KEY"));Verify a webhook
The three verifiers are on the webhooks page, each with the framework line that matters: read the raw body, never the parsed one.
Handle a refusal
js
try {
await chargeOrder(order);
} catch (err) {
if (err.retryable) return scheduleRetry(order); // same Idempotency-Key, later
if (err.code === "BAD_MSISDN") return askForNumberAgain(order);
if (err.code === "AMOUNT_TOO_SMALL") return refuseOrder(order, "below the minimum");
log.error("pamopay refused", { code: err.code, requestId: err.requestId, order: order.id });
throw err;
}Log PamoPay-Request-Id on every call. It is the one string that finds the call on our side without anybody grepping by timestamp.