Build with Elicate Pay
Everything you need to accept mobile money payments and send merchant payouts in Zambia. One API, all networks, real-time webhooks.
curl -X POST https://elicatepay.vercel.app/api/v1/payments/charge \
-H "Authorization: Bearer ep_test_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"amount":50,"phone":"0962000000","network":"MTN"}'Overview#
Elicate Pay is a Zambian mobile money payment gateway. It lets merchants accept payments from MTN, Airtel, and Zamtel wallets. Currency is always ZMW (Zambian Kwacha).
How a payment works
Your server sends a charge request to Elicate Pay
Elicate Pay forwards the charge to Flutterwave
If Zambia mobile money, customer is redirected to Flutterwave verification page (USSD PIN/captcha)
Customer receives a USSD prompt and approves on their phone
Flutterwave sends a callback → Elicate Pay updates the status
Elicate Pay sends a webhook to your server with the result
Base URLs#
https://elicatepay.vercel.apphttp://localhost:3000All endpoints are prefixed with /api/v1/
Authentication#
Elicate Pay uses 2 auth methods depending on the endpoint. Each endpoint section below tells you which one to use.
Secret API Key
Server-sideFor charges, payouts, transactions, and payment links. Set in the Authorization header.
Authorization: Bearer ep_test_sk_...Public API Key
Frontend-safeFor the checkout/pay endpoint only. Sent in the request body, not headers.
{ "public_key": "ep_test_pk_..." }API Key Prefixes#
| Prefix | Mode | Type | Use |
|---|---|---|---|
ep_test_pk_ | Test | Public | Frontend checkout |
ep_test_sk_ | Test | Secret | Server-side API calls |
ep_live_pk_ | Live | Public | Frontend checkout (real money) |
ep_live_sk_ | Live | Secret | Server-side API calls (real money) |
123456when an OTP is required.Charge a Customer#
Initiate a mobile money payment from your backend.
/api/v1/payments/chargeSecret Key| Field | Type | Required | Notes |
|---|---|---|---|
amount | number | Yes | Amount in ZMW, must be > 0 |
phone | string | Yes | Customer's phone number |
network | string | Yes | MTN, AIRTEL, or ZAMTEL |
currency | string | No | Defaults to ZMW |
reference | string | No | Your order/invoice ID. Auto-generated if omitted |
customer_name | string | No | Defaults to "Customer" |
{
"amount": 50.00,
"phone": "0962000000",
"network": "MTN",
"currency": "ZMW",
"reference": "ORDER-12345",
"customer_name": "John Banda"
}{
"transaction_id": "EP-M4K2F9-A1B2",
"status": "pending",
"reference": "ORDER-12345",
"meta": {
"authorization": {
"mode": "redirect",
"redirect_url": "https://flutterwave.com/verify/redirect/xyz..."
},
"message": "Customer must complete verification. If redirect mode, open the redirect_url in an iframe or new tab."
}
}A pending response means the charge was sent to the customer's phone. Depending on the transaction route, the customer must either complete authentication via the redirect_url, or they may receive an OTP through WhatsApp to authorize the payment.
When testing with test API keys, enter 123456 if the sandbox flow asks for an OTP.
Recommended Flow:
- Redirect the customer to
redirect_url. - Once they return to your site (or while they are on an awaiting payment screen), poll the Get Transaction Details endpoint every 3 seconds. This ensures you catch the success status immediately, even if the webhook gets delayed.
- Keep polling running continuously until the status changes to
success(ortest successin test mode) orfailed. If the user refreshes the page, your app should resume polling where it left off using the savedtransaction_id.
| Status | Condition |
|---|---|
| 400 | Missing required fields |
| 400 | Invalid network |
| 401 | Invalid/missing API key |
| 401 | Used public key instead of secret |
| 403 | Merchant suspended/rejected |
| 403 | Live key but account not approved |
| 502 | Flutterwave charge failed |
Platform Enhancements and Upcoming Improvements#
Elicate Pay continuously evolves its payment infrastructure to provide the most seamless payment experience possible for merchants and customers.
The current OTP verification process is part of the existing mobile money authentication flow and helps ensure secure transaction authorization. Depending on the transaction route, OTP verification may currently be delivered through WhatsApp.
Our engineering team and technology partners are actively working on enhancements to the collection experience. Future updates are expected to streamline the authorization process and reduce friction for customers during payment confirmation.
These improvements may include expanded authorization options, improved delivery mechanisms, and more direct transaction approval experiences where supported by participating mobile money networks.
As these enhancements are rolled out, merchants may notice fewer authentication steps and a smoother customer payment journey.
What this means for developers
- The current OTP verification flow is fully supported and production-ready.
- No action is required from existing integrations.
- Future improvements will be introduced in a backward-compatible manner whenever possible.
- Elicate Pay remains committed to reducing payment friction while maintaining security and transaction reliability.
Update notifications
Elicate Pay will provide official updates through multiple channels, including the developer changelog and direct email notifications. When significant enhancements to the OTP verification flow or related payment processes are released, merchants will be notified by email.
Keep your developer account contact email up to date to receive important platform announcements promptly.
Checkout Pay (Frontend-Safe)#
Public endpoint for customer-facing checkout pages. No secret key needed. Two flows:
/api/v1/checkout/payPublic Key or SlugFlow A: Payment Link Slug
{
"link_slug": "a1b2c3d4",
"customer_name": "Jane Mwale",
"phone": "0973000000",
"network": "AIRTEL",
"amount": 75.00
}amount is only required for flexible links. Fixed links use their preset amount.
Flow B: Public Key
{
"public_key": "ep_test_pk_xxx...",
"customer_name": "Jane Mwale",
"phone": "0973000000",
"network": "MTN",
"amount": 100.00,
"description": "Widget purchase",
"reference": "CART-789"
}| Field | Type | Required | Notes |
|---|---|---|---|
link_slug | string | No | Required for Flow A |
public_key | string | No | Required for Flow B |
customer_name | string | Yes | |
phone | string | Yes | |
network | string | Yes | MTN, AIRTEL, or ZAMTEL |
amount | number | No | Required for flexible links and Flow B |
description | string | No | Flow B only |
reference | string | No | Auto-generated if omitted |
{
"transaction_id": "EP-M4K2F9-A1B2",
"status": "redirect",
"reference": "CART-789",
"meta": {
"authorization": {
"mode": "redirect",
"redirect_url": "https://flutterwave.com/verify/redirect/xyz..."
},
"message": "Customer must complete verification. If redirect mode, open the redirect_url in an iframe or new tab."
}
}Check Transaction Status#
No auth needed. Safe to call from frontend.
/api/v1/checkout/status/{transaction_id}None{
"transaction_id": "EP-M4K2F9-A1B2",
"status": "pending",
"amount": 100.00,
"currency": "ZMW",
"reference": "ORDER-12345"
}status values: pending, success, test success (in test mode), failedGet Transaction Details#
Returns full details including fees. Requires secret key. Only returns transactions belonging to your merchant account.
/api/v1/payments/{transaction_id}Secret Key{
"transaction_id": "EP-M4K2F9-A1B2",
"status": "success",
"amount": 100.00,
"fee": 2.60,
"net_amount": 97.40,
"currency": "ZMW",
"reference": "ORDER-12345",
"customer_name": "John Banda",
"customer_phone": "0962000000",
"network": "MTN",
"created_at": 1710000000000,
"updated_at": 1710000060000
}Real-Time Status Stream (SSE)#
Server-Sent Events that push status updates. Polls every 2s. Closes on terminal status or after 55 seconds.
/api/v1/payments/{transaction_id}/streamSecret Key| Event | Meaning |
|---|---|
payment.pending | Waiting for customer approval |
payment.success | Payment completed |
payment.failed | Payment failed or declined |
timeout | 55s timeout — reconnect if needed |
error | Transaction deleted or poll error |
const response = await fetch(
'https://elicatepay.vercel.app/api/v1/payments/EP-XXX/stream',
{ headers: { 'Authorization': 'Bearer ep_test_sk_...' } }
);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(decoder.decode(value));
// event: payment.success
// data: {"transaction_id":"EP-XXX","status":"success","amount":100,...}
}List Payment Links#
/api/v1/payment-linksSecret Key{
"payment_links": [
{
"id": "abc123firebaseDocId",
"name": "Pro Plan Subscription",
"type": "fixed",
"amount": 299,
"min_amount": 0,
"description": "Monthly subscription",
"redirect_url": "https://mysite.com/thanks",
"active": true,
"slug": "a1b2c3d4",
"url": "https://elicatepay.vercel.app/pay/a1b2c3d4",
"created_at": 1710000000000
}
],
"total": 1
}Create a Payment Link#
/api/v1/payment-linksSecret Key| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Display name for the link |
type | string | Yes | "fixed" or "flexible" |
amount | number | No | Required if type is fixed |
min_amount | number | No | Minimum for flexible links. Default 0 |
description | string | No | |
redirect_url | string | No | URL to redirect after payment |
{
"name": "Donation",
"type": "flexible",
"min_amount": 10,
"description": "Support our cause",
"redirect_url": "https://mysite.com/thankyou"
}{
"id": "abc123firebaseDocId",
"name": "Donation",
"type": "flexible",
"amount": 0,
"min_amount": 10,
"description": "Support our cause",
"redirect_url": "https://mysite.com/thankyou",
"active": true,
"slug": "x9y8z7w6",
"url": "https://elicatepay.vercel.app/pay/x9y8z7w6",
"created_at": 1710000000000
}Get a Single Payment Link#
/api/v1/payment-links/{linkId}Secret KeyReturns the same shape as a single item from the list endpoint, plus updated_at.
Update a Payment Link#
/api/v1/payment-links/{linkId}Secret KeyAll fields are optional — include only what you want to change.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | No | |
type | string | No | "fixed" or "flexible" |
amount | number | No | |
min_amount | number | No | |
description | string | No | |
redirect_url | string | No | |
active | boolean | No | Set false to disable |
{
"name": "Updated Name",
"active": false
}Payouts#
Send money from your available Elicate Pay merchant balance to a bank account or mobile money wallet. Payouts require a secret key and are never initiated from frontend code.
Create a Payout#
/api/v1/payoutsSecret Key| Field | Type | Required | Notes |
|---|---|---|---|
amount | number | Yes | Amount in ZMW. Must be greater than 0 and within available balance. |
currency | string | No | Defaults to ZMW. Only ZMW is supported for now. |
account_bank | string | Yes | Flutterwave bank/operator code. For ZMW mobile money, use "MPS". |
account_number | string | Yes | Bank account number or mobile money wallet number. |
beneficiary_name | string | Yes | Recipient full name. |
reference | string | No | Your unique payout reference. Auto-generated if omitted. |
narration | string | No | Statement/narration text. |
{
"amount": 25,
"currency": "ZMW",
"account_bank": "MPS",
"account_number": "260971234567",
"beneficiary_name": "Mary Tembo",
"reference": "PAYOUT-001",
"narration": "Merchant settlement"
}{
"payout_id": "PO-M4K2F9-A1B2",
"status": "pending",
"reference": "PAYOUT-001",
"amount": 25,
"currency": "ZMW",
"available_balance_before_payout": 97.50,
"flutterwave_transfer_id": 8773850,
"message": "Transfer Queued Successfully"
}FLUTTERWAVE_IP_WHITELIST_REQUIRED, whitelist the deployed Elicate Pay server IP before retrying live payouts.Payout Fees#
When initiating a payout, a flat transfer fee is deducted from the merchant's available balance in addition to the requested payout amount. The payout recipient always receives the full requested amount.
| Scenario | Calculation |
|---|---|
| Requested Amount | E.g., 100.00 ZMW |
| Transfer Fee | Dynamic flat fee from Flutterwave (e.g., 15.00 ZMW) |
| Total Deduction | Amount + Fee (e.g., 115.00 ZMW) |
Get Payout Status#
Returns the payout record for the authenticated merchant. Pending payouts are reconciled with Flutterwave when possible.
/api/v1/payouts/{payout_id}Secret Key{
"payout_id": "PO-M4K2F9-A1B2",
"status": "pending",
"amount": 25,
"fee": 0.75,
"currency": "ZMW",
"reference": "PAYOUT-001",
"account_bank": "MPS",
"account_number": "260971234567",
"beneficiary_name": "Mary Tembo",
"flutterwave_transfer_id": 8773850,
"flutterwave_status": "NEW",
"created_at": 1710000000000,
"updated_at": 1710000060000
}status values: pending, success, failedBalance Security#
- Merchants can read their own payouts, but cannot create or edit payout documents directly in Firestore.
- Transactions, payout statuses, fees, and net amounts are server-write-only.
- API-created payouts also appear in the merchant dashboard payout history for audit visibility.
- Merchant profile updates are limited to safe profile fields, not financial or approval fields.
Webhooks#
When a payment or payout completes (success or fail), Elicate Pay sends a POST webhook to your configured URL.
Payload Format#
{
"event": "payment.success",
"data": {
"transaction_id": "EP-M4K2F9-A1B2",
"reference": "ORDER-12345",
"amount": 100.00,
"fee": 2.50,
"net_amount": 97.50,
"currency": "ZMW",
"customer_name": "John Banda",
"customer_phone": "0962000000",
"network": "MTN",
"status": "success"
}
}status field inside the payload data will be test success for test transactions and success for live transactions.{
"event": "payout.success",
"data": {
"payout_id": "PO-M4K2F9-A1B2",
"reference": "PAYOUT-001",
"amount": 25,
"fee": 0.75,
"currency": "ZMW",
"account_bank": "MPS",
"account_number": "260971234567",
"beneficiary_name": "Mary Tembo",
"flutterwave_transfer_id": 8773850,
"status": "success"
}
}| Event | Meaning |
|---|---|
payment.success | Payment approved and completed |
payment.failed | Payment failed or declined |
payout.success | Payout completed successfully |
payout.failed | Payout failed |
payment.test | Test webhook from the test endpoint |
Signature Verification#
Every webhook includes an HMAC-SHA256 signature in the X-ElicatePay-Signature header.
How to verify
Get the raw request body as a string
Compute HMAC-SHA256 of that string using your webhook secret
Compare with the X-ElicatePay-Signature header (use constant-time comparison)
const crypto = require('crypto');
function verifyWebhook(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express middleware
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-elicatepay-signature'];
const rawBody = req.body.toString();
if (!verifyWebhook(rawBody, sig, 'your-webhook-secret')) {
return res.status(401).send('Invalid signature');
}
const { event, data } = JSON.parse(rawBody);
if (event === 'payment.success') {
// Fulfill the order
}
res.status(200).send('OK');
});import hmac, hashlib
def verify_webhook(raw_body: str, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), raw_body.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Flask
@app.route('/webhook', methods=['POST'])
def webhook():
sig = request.headers.get('X-ElicatePay-Signature', '')
raw = request.get_data(as_text=True)
if not verify_webhook(raw, sig, 'your-webhook-secret'):
return 'Invalid signature', 401
payload = request.get_json()
if payload['event'] == 'payment.success':
pass # Fulfill the order
return 'OK', 200$rawBody = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_ELICATEPAY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, 'your-webhook-secret');
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit('Invalid signature');
}
$payload = json_decode($rawBody, true);
if ($payload['event'] === 'payment.success') {
// Fulfill the order
}
http_response_code(200);
echo 'OK';Retry Policy#
If your server returns a non-2xx response or is unreachable:
Test Webhook Delivery#
Send a test webhook to any URL. No auth required.
/api/v1/webhooks/testNone{
"url": "https://your-server.com/webhook",
"secret": "your-webhook-secret"
}{
"status": "delivered",
"httpStatus": 200
}Handling Redirect Mode (Iframe Embed)#
mode: "redirect" and a redirect_url. You must open this URL in an iframe or a new tab for the customer to complete verification. This step cannot be skipped.<iframe src={redirectUrl} width="100%" height="420" style={{ borderRadius: 12, border: '1px solid #eee' }} />If the iframe is blocked, show a button to open the redirect_url in a new tab.
Node.js / Express#
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET_KEY = 'ep_test_sk_your_key_here';
const BASE = 'https://elicatepay.vercel.app';
const WH_SECRET = 'your-webhook-secret';
// 1. Charge a customer
app.post('/pay', express.json(), async (req, res) => {
const { phone, network, amount, name } = req.body;
const response = await fetch(`${BASE}/api/v1/payments/charge`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount, phone, network,
customer_name: name,
reference: `ORDER-${Date.now()}`,
}),
});
const data = await response.json();
if (!response.ok) return res.status(response.status).json({ error: data.error });
// Save data.transaction_id with your order in your database
res.json({ message: 'Check your phone to approve', transaction_id: data.transaction_id });
});
// 2. Receive webhooks
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-elicatepay-signature'];
const raw = req.body.toString();
const expected = crypto.createHmac('sha256', WH_SECRET).update(raw).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig || ''), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
const { event, data } = JSON.parse(raw);
if (event === 'payment.success') {
console.log(`Payment ${data.transaction_id} succeeded! Net: ${data.net_amount} ZMW`);
// Mark order as paid in your database
}
res.status(200).send('OK');
});
app.listen(3001);Python / Flask#
import requests, hmac, hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET_KEY = "ep_test_sk_your_key_here"
BASE = "https://elicatepay.vercel.app"
WH_SECRET = "your-webhook-secret"
@app.route("/pay", methods=["POST"])
def pay():
data = request.get_json()
resp = requests.post(
f"{BASE}/api/v1/payments/charge",
headers={"Authorization": f"Bearer {SECRET_KEY}", "Content-Type": "application/json"},
json={
"amount": data["amount"],
"phone": data["phone"],
"network": data["network"],
"customer_name": data.get("name", "Customer"),
},
)
result = resp.json()
if resp.status_code != 200:
return jsonify({"error": result.get("error")}), resp.status_code
return jsonify({"message": "Check your phone", "transaction_id": result["transaction_id"]})
@app.route("/webhook", methods=["POST"])
def webhook():
sig = request.headers.get("X-ElicatePay-Signature", "")
raw = request.get_data(as_text=True)
expected = hmac.new(WH_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return "Invalid signature", 401
payload = request.get_json()
if payload["event"] == "payment.success":
print(f"Payment {payload['data']['transaction_id']} succeeded!")
return "OK", 200PHP#
<?php
$SECRET_KEY = 'ep_test_sk_your_key_here';
$BASE = 'https://elicatepay.vercel.app';
function charge(float $amount, string $phone, string $network): array {
global $SECRET_KEY, $BASE;
$ch = curl_init("$BASE/api/v1/payments/charge");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $SECRET_KEY",
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => $amount,
'phone' => $phone,
'network' => $network,
]),
]);
$res = curl_exec($ch);
curl_close($ch);
return json_decode($res, true);
}
// webhook.php — verify and handle
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_ELICATEPAY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $raw, 'your-webhook-secret');
if (!hash_equals($expected, $sig)) { http_response_code(401); exit('Bad sig'); }
$payload = json_decode($raw, true);
if ($payload['event'] === 'payment.success') {
// Mark order as paid
}
http_response_code(200);Next.js / React — Frontend Checkout#
'use client';
import { useState } from 'react';
export default function CheckoutForm({ publicKey }: { publicKey: string }) {
const [phone, setPhone] = useState('');
const [network, setNetwork] = useState('MTN');
const [status, setStatus] = useState<'idle'|'loading'|'pending'|'success'|'failed'>('idle');
async function handlePay() {
setStatus('loading');
const res = await fetch('https://elicatepay.vercel.app/api/v1/checkout/pay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
public_key: publicKey,
customer_name: 'Customer',
phone, network,
amount: 100,
}),
});
const data = await res.json();
if (!res.ok) { alert(data.error); setStatus('idle'); return; }
setStatus('pending');
// Poll for result
const poll = setInterval(async () => {
const s = await fetch(`https://elicatepay.vercel.app/api/v1/checkout/status/${data.transaction_id}`);
const st = await s.json();
if (st.status === 'success') { clearInterval(poll); setStatus('success'); }
if (st.status === 'failed') { clearInterval(poll); setStatus('failed'); }
}, 3000);
setTimeout(() => clearInterval(poll), 120000);
}
return (
<div>
<input placeholder="Phone" value={phone} onChange={e => setPhone(e.target.value)} />
<select value={network} onChange={e => setNetwork(e.target.value)}>
<option>MTN</option><option>AIRTEL</option><option>ZAMTEL</option>
</select>
<button onClick={handlePay} disabled={status==='loading'||status==='pending'}>
{status==='pending' ? 'Waiting...' : 'Pay K100'}
</button>
{status==='success' && <p>Payment successful!</p>}
{status==='failed' && <p>Payment failed.</p>}
</div>
);
}cURL#
curl -X POST https://elicatepay.vercel.app/api/v1/payments/charge \
-H "Authorization: Bearer ep_test_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"amount":50,"phone":"0962000000","network":"MTN","customer_name":"John"}'curl https://elicatepay.vercel.app/api/v1/checkout/status/EP-M4K2F9-A1B2curl -X POST https://elicatepay.vercel.app/api/v1/payment-links \
-H "Authorization: Bearer ep_test_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"name":"Product","type":"fixed","amount":250}'curl -X POST https://elicatepay.vercel.app/api/v1/payouts \
-H "Authorization: Bearer ep_test_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"amount":25,"currency":"ZMW","account_bank":"MPS","account_number":"260971234567","beneficiary_name":"Mary Tembo","reference":"PAYOUT-001"}'curl -X POST https://elicatepay.vercel.app/api/v1/webhooks/test \
-H "Content-Type: application/json" \
-d '{"url":"https://your-server.com/webhook","secret":"your-secret"}'Error Code Reference#
| Status | Meaning |
|---|---|
| 200 | Success |
| 201 | Created (payment link) |
| 400 | Bad request — missing or invalid fields |
| 401 | Auth failed — invalid/missing key or token |
| 402 | Insufficient merchant balance for payout |
| 403 | Forbidden — suspended, not approved, or not admin |
| 404 | Resource not found |
| 409 | Conflict — resource already exists |
| 410 | Gone — inactive or deprecated |
| 502 | Bad gateway — Flutterwave call failed |
| 503 | Service unavailable — server config error |
All errors return:
{ "error": "Human-readable error message" }Important Notes
- Currency: Always ZMW. Do not send other currencies.
- Networks: Only MTN, AIRTEL, ZAMTEL. Send uppercase.
- Phone: Zambian format, e.g. 0962000000
- Fees: 2.6% on successful transactions.
- Timestamps: Unix milliseconds (not seconds).
- Transaction IDs: Format EP-XXXXXX-XXXX
- Test vs Live: Same endpoints, same format. Only the key prefix changes.