Skip to content

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 charge
php
<?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.

Every code, scope and route on this site is rendered from the API's own source.