PlanetPay API — v1
The PlanetPay API lets your website or app, from your PlanetPay balance:
-
collect payments (Mobile Money in 20 African countries and international cards);
-
send money to Mobile Money accounts;
-
resell digital services (licences, tool rentals, credits, IMEI);
-
buy virtual numbers to receive an SMS code;
-
create and manage Visa / Mastercard virtual cards.
-
Base URL:
https://planetpay.site -
Format: JSON (
Content-Type: application/json) -
Account currency: USD. Amounts you send are in USD; PlanetPay converts to the local currency at the current rate.
Contents
- Quick start
- Authentication
- Test mode
- Endpoints
- Digital services (licences, tool rental, credits)
- Virtual numbers (SMS codes)
- Visa virtual cards
- Embedded checkout (iframe)
- SMM panel plugin
- Webhooks
- Partner API (connected platforms)
- Errors
- Full examples
Quick start
- Sign in to your PlanetPay account, open API Credentials and create a key. You get an API key (public) and a secret key (keep it on your server, never in a mobile app or browser JavaScript).
- New keys are in test mode. Test your integration, then switch the key to production (identity verification required).
- Collect:
POST /v1/rechargereturns apayment_urlto redirect your customer to. Your balance is credited on confirmation and we call yourcallback_url.
Authentication
Every request carries three headers:
| Header | Value |
|---|---|
X-Api-Key |
your API key |
X-Timestamp |
current time in Unix seconds (± 5 minutes allowed) |
X-Signature |
HMAC-SHA256(secret_key, string_to_sign) as lowercase hex |
The string to sign is:
{X-Timestamp}.{METHOD}.{path}.{raw_body}
METHODin uppercase (GET,POST)pathwithout domain or query string, e.g./v1/rechargeraw_body: the exact JSON sent, byte for byte (empty string forGET)
Example for GET /v1/balance at 1792180000: 1792180000.GET./v1/balance.
The secret key never travels on the network, and a captured request cannot be replayed after 5 minutes.
Rate limit: 120 requests per minute per key (429 beyond).
Test mode
A test key simulates everything, with no money movement:
GET /v1/balancealways returns 1,000 USD;POST /v1/rechargereturns apayment_urlto a simulator where you choose "successful" or "failed" payment (same integration as production, iframe included);POST /v1/transferiscompleted, unless the number ends with 4444: it is thenfailed(to test your error handling);- webhooks are sent as in production;
- IDs start with
rch_test_/trf_test_. POST /v1/services/{id}/ordersis delivered at once with a fakeTEST-…result;POST /v1/cardsreturns a fake card (last4=4242), andfund/withdrawarecompletedwithout any debit.POST /v1/numbersreturns a fake number (+10000000000) that has already "received" the code123456(status: completed), without any debit; IDs start withnum_test_.
Endpoints
GET /v1/account
{ "object": "account", "id": "acct_33", "name": "Awa Koné", "email": "awa@example.com",
"kyc_verified": true, "currency": "USD", "mode": "live" }
GET /v1/balance
{ "object": "balance", "mode": "live", "available": [ { "currency": "USD", "amount": 70 }, { "currency": "EUR", "amount": 0 } ] }
GET /v1/operators — networks for transfers
Mobile Money networks you can send money to with /v1/transfer. This list is not used for collections: /v1/recharge asks for no operator.
{ "object": "list", "data": [
{ "country": "CI", "country_name": "Côte d'Ivoire", "operator": "ORANGE", "name": "Orange Money",
"currency": "XOF", "dial_code": "225", "transfer": true, "recharge": true } ] }
POST /v1/recharge — collect a payment
Creates a payment request. Redirect your customer to payment_url (or open it in an iframe): they pay by Mobile Money or card on the secure PlanetPay page. You do not choose nor send any operator: just give the currency, the page offers the payment methods of the customer's country automatically. Your USD balance is credited with amount on confirmation.
| Field | Type | Required | Description |
|---|---|---|---|
amount |
number | yes | amount to credit, in USD. No amount limit |
currency |
string | yes | customer's payment currency: XOF, XAF, GHS, GNF, KES, NGN, UGX, CDF, RWF, SLE, TZS, ZMW (Mobile Money) or USD (international card) |
reference |
string | yes | your unique reference (letters, digits, -_., max 100) |
callback_url |
https URL | no | called on confirmation (see Webhooks) |
return_url |
https URL | no | page the customer returns to after paying |
Response 201:
{
"id": "rch_vcxzimvbpzaau6oxipm6", "object": "recharge", "reference": "ORDER-1001",
"amount": 20, "account_currency": "USD", "amount_due": 11698, "currency": "XOF",
"mode": "live", "status": "pending",
"payment_url": "https://planetpay.site/pay/checkout/PA261016…",
"expires_at": "2026-10-17T20:00:00+00:00", "created_at": "2026-10-16T20:00:00+00:00"
}
amount_due is what your customer pays in their currency (method fees included). The link expires after 24 h.
POST /v1/transfer — send Mobile Money
Debits your balance and sends money to a Mobile Money account.
| Field | Type | Required | Description |
|---|---|---|---|
amount |
number | yes | amount in USD (converted to the local currency) |
country |
string | yes | ISO 2-letter country code (CI, SN, CM…) |
operator |
string | yes | operator code from /v1/operators (ORANGE, MTN, WAVE…) |
phone |
string | yes | recipient number, local or international format |
reference |
string | yes | your unique reference |
callback_url |
https URL | no | called on confirmation |
Response 201:
{
"id": "trf_k2j5…", "object": "transfer", "reference": "PAYOUT-77",
"amount": 10, "fee": 0.8, "total_debited": 10.8, "account_currency": "USD",
"receive_amount": 5848, "receive_currency": "XOF", "country": "SN", "operator": "WAVE",
"phone": "221770000000", "mode": "live", "status": "pending", "created_at": "2026-10-16T20:05:00+00:00"
}
If the transfer fails, the debited amount is refunded automatically and the status becomes failed.
GET /v1/transactions/{id}
Returns the operation (rch_…, trf_…, ord_…, num_…, crd_…, cfd_… or cwd_…) with its current status.
Statuses
| Status | Meaning |
|---|---|
pending |
waiting for confirmation |
completed |
confirmed: balance credited (recharge) or money received (transfer) |
failed |
failed; for a transfer, your balance was refunded |
Idempotency
reference is unique per operation type and mode. Sending a request again with the same reference does not create a duplicate: you get the existing operation (code 200 instead of 201). When in doubt (network error), simply resend the same request.
Digital services
Resell on your website the GSM software licences and activations, hourly tool rentals (UnlockTool, Chimera, TSM, AMT…), credits and IMEI / serial number services of the PlanetPay catalogue.
How it works
GET /v1/services: read the catalogue (prices in USD, final).POST /v1/services/{id}/orders: order for your customer; the price is debited from your USD balance.- The result (code, credentials, activation) comes in the response, or later by webhook.
- If the supplier refuses or fails, your balance is refunded automatically (
refunded: true).
GET /v1/services — catalogue
| Parameter | Description |
|---|---|
category |
rental (tool rental), licence (licences and credits), imei (IMEI / SN services) |
q |
search in the name and the tool |
page, limit |
pagination (limit: 50 by default, 100 max) |
{
"object": "list", "total": 28, "page": 1, "limit": 50,
"data": [{
"id": 130, "object": "service", "name": "AMT - Android Multi Tool Rent [2 Hours]", "category": "rental",
"tool": "Digital Tool", "price": 0.34, "currency": "USD", "delivery_time": "Instant", "image": null,
"requires": [{ "name": "identifier", "label": "Main identifier (IMEI, serial or account)", "required": true }],
"quantity": null, "info": null
}]
}
| Field | Description |
|---|---|
price |
unit price debited, in USD |
requires |
fields to send with the order: always identifier, the others in fields |
quantity |
{ "min": 1, "max": 100 } for services sold by quantity (credits), otherwise null |
delivery_time |
indicative delay announced by the supplier |
GET /v1/services/{id} — one service
Same format as a list item. 404 if the service does not exist or is no longer sold.
POST /v1/services/{id}/orders — order
| Field | Type | Required | Description |
|---|---|---|---|
identifier |
text (120 max) | yes | main identifier: tool username, IMEI, serial number… |
fields |
object | per requires |
other fields, e.g. {"email": "client@mail.com"} |
quantity |
integer | no | services sold by quantity (kept within min / max) |
reference |
text | yes | your unique reference (idempotency) |
callback_url |
https URL | no | called on delivery or refund |
201 response:
{
"id": "ord_x8k2…", "object": "service_order", "reference": "RENT-1001",
"service": { "id": 130, "name": "AMT - Android Multi Tool Rent [2 Hours]" },
"quantity": 1, "amount": 0.34, "currency": "USD", "mode": "live",
"status": "completed", "result": "User: … Password: …",
"created_at": "2026-10-24T18:26:36+00:00", "completed_at": "2026-10-24T18:26:36+00:00"
}
status |
Meaning |
|---|---|
pending |
order sent to the supplier; the result comes with the service_order.completed webhook |
completed |
delivered: the result is in result |
failed |
refused or failed: error gives the reason, refunded is true (balance credited back) |
Follow-up: GET /v1/transactions/{id} with the ord_… ID.
Virtual numbers
Buy a temporary phone number to receive one SMS verification code (WhatsApp, Telegram, Google, Facebook, TikTok…), in the country of your choice. The price is debited from your USD balance when the number is assigned.
If no SMS arrives before the number expires (about 20 minutes), or if you cancel, the order is refunded automatically.
How it works
GET /v1/numbers/services: the list of services (apps).GET /v1/numbers/services/{service}/countries: available countries for this service, with price and stock.POST /v1/numbers: buy a number and give it to your customer.- The code comes with the
number.completedwebhook, or by pollingGET /v1/numbers/{id}every 5 to 10 seconds.
GET /v1/numbers/services — services
{
"object": "list", "total": 2,
"data": [
{ "service": "telegram", "name": "Telegram" },
{ "service": "whatsapp", "name": "WhatsApp" }
]
}
Use the service value in the next calls.
GET /v1/numbers/services/{service}/countries — countries and prices
{
"object": "list", "service": "whatsapp",
"data": [
{ "country": "usa", "name": "United States", "price": 0.31, "currency": "USD", "stock": 340 },
{ "country": "france", "name": "France", "price": 0.61, "currency": "USD", "stock": 12 }
]
}
Countries are sorted from cheapest to most expensive. price is the final price debited; stock the number of numbers available when read. Prices and stock move: read them again right before buying.
POST /v1/numbers — buy a number
| Field | Type | Required | Description |
|---|---|---|---|
service |
text | yes | service value from /v1/numbers/services (e.g. whatsapp) |
country |
text | yes | country value from /v1/numbers/services/{service}/countries (e.g. france) |
reference |
text | yes | your unique reference (idempotency) |
callback_url |
https URL | no | called when the SMS arrives or on refund |
201 response:
{
"id": "num_hejqeog9olf0ufukhzii", "object": "number", "reference": "OTP-1001", "mode": "live",
"service": "whatsapp", "country": "france", "number": "+33612345671",
"price": 0.61, "currency": "USD", "status": "pending",
"sms": null, "code": null, "refunded": false, "reason": null,
"expires_at": "2026-10-28T07:58:09+00:00", "created_at": "2026-10-28T07:38:09+00:00", "completed_at": null
}
GET /v1/numbers/{id} — status and code received
Returns the number with its current status (PlanetPay asks the supplier on each call, at most once every 5 seconds). Once the SMS has arrived:
{ "id": "num_hejqeog9olf0ufukhzii", "status": "completed", "sms": "Your WhatsApp code: 482-913", "code": "482913", "…": "…" }
status |
Meaning |
|---|---|
pending |
number assigned, waiting for the SMS |
completed |
SMS received: full text in sms, extracted code in code |
failed |
no SMS before expires_at, or cancelled: refunded is true, reason gives the cause |
GET /v1/transactions/{id} returns the same.
POST /v1/numbers/{id}/cancel — cancel
Cancels a number that has not received an SMS yet; your balance is credited back at once (status: failed, refunded: true). A number that already received its SMS cannot be cancelled (409 already_completed).
Tips: buy the number only when your customer is ready to type the code; one number is for one code; if the app refuses the number, cancel and buy again in another country.
Visa virtual cards
Create Visa or Mastercard virtual cards in USD for yourself or your customers, fund them and withdraw their balance, from your PlanetPay balance. The cards work for online payments (subscriptions, advertising, e-commerce…).
Requirements (same as in the PlanetPay app): verified account (KYC), cardholder profile with a phone number, active card plan, maximum number of cards of your plan. The issuing, funding and withdrawal fees of your plan are debited from your USD balance and shown in your account.
Security: the API never returns the full card number nor the CVV, only the last 4 digits (
last4). The cardholder sees the full details in their PlanetPay account, after authentication.
How it works
POST /v1/cards: create the card (issuing fee debited).POST /v1/cards/{id}/fund: load the card from your balance.- Pay with the card.
POST /v1/cards/{id}/withdraw: move the remaining card balance back to your balance.
The card object
{
"id": "card_01JB7Q9X…", "object": "card", "name": "Awa Traore", "brand": "Visa", "last4": "4821",
"expiry": "10/29", "balance": 25.0, "currency": "USD", "status": "enabled", "created_at": "2026-10-24T18:30:00+00:00"
}
| Field | Description |
|---|---|
id |
card ID (card_…), used in the other calls |
name |
name printed on the card |
brand |
Visa or Mastercard |
last4, expiry |
last 4 digits, expiry date MM/YY |
balance |
card balance in USD |
status |
enabled = usable; any other value = card blocked or closed |
GET /v1/cards — your cards
{ "object": "list", "mode": "live", "data": [ { "id": "card_…", "object": "card", "…": "…" } ] }
With a test key the list is empty: test cards only exist in the responses of POST /v1/cards.
GET /v1/cards/{id} — one card
The card, with its balance, last 4 digits and expiry read live from the issuer.
POST /v1/cards — create a card
| Field | Type | Required | Description |
|---|---|---|---|
name_on_card |
text (4 to 50) | yes | name printed on the card |
brand |
visa | mastercard |
no | visa by default |
reference |
text | yes | your unique reference (idempotency) |
callback_url |
https URL | no | notification address |
201 response:
{
"id": "crd_p3k9…", "object": "card_create", "reference": "CARD-1001", "mode": "live", "status": "completed",
"amount": null, "created_at": "2026-10-24T18:30:00+00:00",
"card": { "id": "card_01JB7Q9X…", "object": "card", "name": "Awa Traore", "brand": "Visa", "last4": "4821",
"expiry": "10/29", "balance": 0.0, "currency": "USD", "status": "enabled" }
}
POST /v1/cards/{id}/fund — fund
| Field | Type | Required | Description |
|---|---|---|---|
amount |
number (USD, 1 to 100,000) | yes | amount loaded on the card (funding fees come on top) |
reference |
text | yes | your unique reference |
callback_url |
https URL | no | notification address |
201 response (cfd_…) with amount and the updated card in card.
POST /v1/cards/{id}/withdraw — withdraw to your balance
Same fields as funding. The amount goes back to your USD balance (the withdrawal fees of your plan apply). 201 response (cwd_…).
Card errors
| HTTP | code |
Cause |
|---|---|---|
| 402 | insufficient_balance |
PlanetPay balance (or card balance, for a withdrawal) too low |
| 403 | kyc_required |
production key on an unverified account |
| 404 | not_found |
unknown card, or card of another account |
| 422 | card_refused |
plan rule not met (no plan, card limit, missing cardholder profile or phone, amount…); message gives the reason |
| 502 | card_failed |
the issuer could not create the card; check your history, then try again later |
Embedded checkout (iframe)
Your customer pays without leaving your site: the PlanetPay window opens over your page (full screen on mobile).
<script src="https://planetpay.site/js/planetpay.js"></script>
<button id="pay">Pay</button>
<script>
document.getElementById('pay').onclick = async function () {
// Your server calls POST /v1/recharge and returns payment_url (never put the secret key in the browser)
const { payment_url } = await fetch('/my-route/create-payment', { method: 'POST' }).then(r => r.json());
PlanetPay.open(payment_url, {
onSuccess: function (e) { /* show "thank you"; fulfilment waits for the webhook */ },
onFailure: function (e) { /* offer to retry */ },
onClose: function (e) { }
});
};
</script>
| Option | Purpose |
|---|---|
onSuccess(e) |
the customer paid (e.reference, e.mode) |
onFailure(e) |
the payment failed |
onClose(e) |
the window was closed |
autoClose |
close automatically after success (true by default, autoCloseDelay = 2500 ms) |
closeOnBackdrop |
close when clicking outside (true by default) |
Without JavaScript, simply redirect to payment_url (with return_url) or use <iframe src="payment_url" allow="payment *">. The checkout then sends your page postMessage events shaped { "source": "planetpay", "event": "ready" | "success" | "failed" | "close", "reference": "…" }; check that event.origin is https://planetpay.site.
Important: the browser event is for display only. Fulfil an order only after the signed webhook or GET /v1/transactions/{id}.
SMM panel plugin
Running an SMM panel (PHP + MySQL)? Your customers add funds by Mobile Money or card, in a PlanetPay window inside the panel, and the balance is credited automatically.
📦 Download the plugin (version 2.1, PHP 7.4+, no dependency)
- Upload the
planetpayfolder to the panel's public folder (https://your-panel.com/planetpay/). - Run
install.sqlin the panel database. - Copy
config.sample.phptoconfig.php: your API keys, the database, the balance table and column (oftenusers/balance), the session key of the logged-in customer. - On the "Add funds" page:
<?php include $_SERVER['DOCUMENT_ROOT'] . '/planetpay/widget.php'; ?> - Add the safety-net cron every 5 minutes:
php /path/to/planetpay/cron.php(credits payments whose webhook was missed). - Test with a test key while logged in with an account listed in
test_users("Simulate a successful payment"), then switch to production. Test payments are never credited to other customers.
The plugin identifies the customer from the panel session, verifies the webhook signature and the operation with the API, then credits the amount actually paid, once. Full instructions in README.md.
Webhooks
When an operation is confirmed or fails, PlanetPay sends a JSON POST to your callback_url:
{ "event": "recharge.completed",
"data": { "id": "rch_…", "object": "recharge", "reference": "ORDER-1001", "status": "completed", "…": "…" } }
Events: recharge.completed, recharge.failed, transfer.completed, transfer.failed, service_order.completed, service_order.failed, number.completed (SMS received, code in data.code), number.failed (number refunded).
Always verify the signature before fulfilling an order:
| Header | Value |
|---|---|
X-PlanetPay-Timestamp |
send time (Unix seconds) |
X-PlanetPay-Signature |
HMAC-SHA256(secret_key, timestamp + "." + raw_body) |
Reply with a 2xx code. Otherwise we retry 5 times (after 10 s, 1 min, 5 min, 15 min and 1 h). Only public https addresses are called.
Partner API (connected platforms)
This API is for companies that already run a platform in production (exchange, investment, payments) and want to send their real trades to PlanetPay so they appear on the market (prices, volumes, candles). It is not a project showcase: only verifiable data are published.
Connecting in 4 steps
- Application: in your PlanetPay account, menu Connect my platform (company, HTTPS site, compliance, URL of your audit API).
- Domain proof: DNS TXT record
planetpay-verify=<token>(on@or_planetpay), read through Google DNS-over-HTTPS, or a filehttps://<domain>/.well-known/planetpay-verify.txtcontaining the token. Checked every hour, then every day; proof missing 3 times in a row = connection suspended. - Review and test phase: the PlanetPay team checks compliance and opens the test phase (
pk_test_…keys). Approval requires a verified domain and an audit API that answered at least once. - Production:
pk_live_…keys, real trades, double check of every trade.
A key secret (psk_…) is shown only once and stays encrypted on our side. Two active keys at most per mode (rotation: create the new one, switch, revoke the old one).
Partner authentication
Base: https://planetpay.site/v1/partner (also under /api/v1/partner). HTTPS only, UTF-8 JSON, 64 KB max.
| Header | Value |
|---|---|
X-Partner-Key (or X-Partner-Id) |
pk_test_… / pk_live_… |
X-Timestamp (or X-Partner-Timestamp) |
Unix time in seconds (±5 min) |
X-Signature (or X-Partner-Signature) |
hex(HMAC-SHA256(secret, timestamp + "." + METHOD + "." + path + "." + raw_body)) |
path = the path called, without domain nor query string (/v1/partner/events). A signature is accepted once (replay protection): sign every request again, retries included. Limit: 300 requests/min per key.
Endpoints
| Method | Path | Use |
|---|---|---|
| GET | /v1/partner/health |
service state, no authentication |
| GET | /v1/partner/status |
connection check: partner, status, mode, domain, live allowed |
| POST | /v1/partner/events |
send one event → 202 accepted, 200 duplicate, 409 conflict, 422 invalid |
| GET | /v1/partner/events/{event_id} |
status of an event you sent |
| GET | /v1/partner/events?status=&type=&limit=&before= |
list (newest first, cursor next_before) |
| GET | /v1/partner/assets |
your assets known to PlanetPay: status (listed or not), last verified price, 24 h change and volume |
| GET | /v1/partner/transactions/{trade_id} |
one trade as PlanetPay sees it: kept or reversed, double check, in the market data or not, commissions |
| GET | /v1/partner/commissions?status=&limit=&before= |
commission lines (newest first, cursor next_before) |
| GET | /v1/partner/reports?from=YYYY-MM-DD&to=YYYY-MM-DD |
period report (default 30 days, 366 max): events, trades, volume per asset, commissions, statements, fee rule |
Event format
{
"event_id": "evt_20261028_000123",
"type": "trade.executed",
"occurred_at": "2026-10-28T09:30:12Z",
"data": {
"trade_id": "TR-88412", "order_id": "OR-5521", "asset_id": "SNTS",
"asset_name": "Sonatel", "asset_type": "shares",
"side": "buy", "quantity": "10", "price": "25500", "currency": "XOF",
"fee": "150", "source_ref": "https://api.my-platform.com/trades/TR-88412"
}
}
| type | required in data |
|---|---|
trade.executed |
trade_id, asset_id, side, quantity, price, currency |
trade.partially_executed |
trade_id, order_id, asset_id, side, quantity, price, currency |
order.created, order.accepted |
order_id, asset_id, side, quantity, currency |
order.cancelled, order.rejected |
order_id, asset_id |
transaction.settled, transaction.reversed |
transaction_id, amount, currency (+ trade_id to cancel a trade) |
asset.price_updated |
asset_id, price, currency (indicative price, not used for candles) |
Rules: event_id unique on your side (sent again identical = duplicate, other content = 409); occurred_at ISO 8601 with time zone, not in the future, max 30 days old; positive decimals as strings (8 decimals max); trade_id identifies one fill (never counted twice). An order is never treated as an executed trade. Each asset belongs to your platform: never mixed with other partners' prices.
Double check (anti-fraud)
A received trade is not published at once. PlanetPay calls your audit API and keeps the trade only if it exists in your system with the same asset, quantity, price and currency, with the status settled.
GET https://api.my-platform.com/planetpay/audit/transaction/{id}
X-PlanetPay-Partner: ptn_…
X-PlanetPay-Timestamp: 1761643812
X-PlanetPay-Signature: hex(HMAC-SHA256(key_secret, timestamp + ".GET." + path_and_query + "."))
200 {"trade_id":"TR-88412","asset_id":"SNTS","quantity":"10","price":"25500","currency":"XOF","status":"settled"}
- Not
settledyet: retried after 1, 5, 15 min, 1, 3, 6 and 12 h, then given up (not published). - 404 three times, other figures, or status
reversed/cancelled/failed: mismatch, trade refused. - 3 mismatches within 24 h: platform suspended automatically.
- HTTPS on your verified domain only, no redirect, answer < 8 s and < 16 KB.
Partner webhooks (notifications from PlanetPay)
Set an HTTPS URL on your domain (tab Webhooks): PlanetPay tells you as soon as something happens, no need to poll the API.
| Event | When |
|---|---|
event.accepted / event.rejected |
an event you sent is accepted / rejected (with the error) |
trade.verified |
trade confirmed by the double check, published |
trade.mismatch / trade.failed |
trade refused / never confirmed |
partner.status_changed |
your platform status changed |
asset.status_changed |
one of your assets listed or suspended |
key.revoked |
an API key revoked |
webhook.test |
test sent from the console |
POST https://api.my-platform.com/planetpay/webhooks
X-PlanetPay-Event: trade.verified
X-PlanetPay-Delivery: whd_… (same on every retry: deduplicate on it)
X-PlanetPay-Timestamp: 1761643815
X-PlanetPay-Signature: hex(HMAC-SHA256(whsec_…, timestamp + "." + raw_body))
{"id":"whd_…","type":"trade.verified","created_at":"2026-10-28T09:30:15Z","partner_id":"ptn_…","mode":"live",
"data":{"trade_id":"TR-88412","asset_id":"SNTS","quantity":"10","price":"25500","currency":"XOF","attempts":1,"error":null}}
Answer a 2xx within 10 s (process later in a queue). Otherwise retried after 1, 5, 30 min, 2, 6, 12 and 24 h, then failed (resend from the console). The whsec_… secret is separate from your API keys and shown only once.
PlanetPay commissions
PlanetPay charges a commission on each trade you report, as set in your contract: buy percentage, sell percentage and/or a fixed amount per trade (in the trade currency). The rule is frozen on the line when the trade arrives: a fee change never rewrites history, and an event received several times creates only one line.
| Status | Meaning |
|---|---|
pending |
trade not yet confirmed by the double check, or still inside the reversal window (3 days by default) |
earned |
earned |
invoiced |
on a monthly statement |
paid |
statement paid |
cancelled |
trade reversed (transaction.reversed) or not confirmed (mismatch, failed) |
Earned live commissions of a finished month are grouped on a monthly statement per currency (PS…). A trade reversed after invoicing creates a negative adjustment line on the next statement. Test-mode commissions are only a simulation and are never invoiced. Details and CSV export: "Commissions" tab of your dashboard.
Partner errors
| HTTP | code |
|---|---|
| 400 | invalid_json, https_required |
| 401 | unauthorized, timestamp_expired, replayed_request |
| 403 | partner_suspended, partner_disconnected, partner_rejected, live_not_allowed, not_in_test_phase |
| 409 | event_conflict |
| 413 | payload_too_large |
| 422 | invalid_event (stored as rejected; send it again corrected with the same event_id) |
| 429 | rate_limited |
| 503 | service_unavailable |
Full example (PHP)
// 1. send an executed trade
$body = json_encode($event, JSON_UNESCAPED_SLASHES);
$ts = (string) time();
$sig = hash_hmac('sha256', $ts . '.POST./v1/partner/events.' . $body, getenv('PLANETPAY_PARTNER_SECRET'));
$ch = curl_init('https://planetpay.site/v1/partner/events');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Partner-Key: ' . getenv('PLANETPAY_PARTNER_KEY'), "X-Timestamp: $ts", "X-Signature: $sig"]]);
$res = curl_exec($ch);
// 2. your audit API (Laravel)
Route::get('/planetpay/audit/transaction/{id}', function (Request $r, string $id) {
$ts = $r->header('X-PlanetPay-Timestamp');
$ok = abs(time() - (int) $ts) <= 300 && hash_equals(hash_hmac('sha256', $ts . '.GET.' . $r->getRequestUri() . '.', env('PLANETPAY_PARTNER_SECRET')), (string) $r->header('X-PlanetPay-Signature'));
abort_unless($ok, 401);
$t = Trade::where('reference', $id)->firstOrFail();
return ['trade_id' => $t->reference, 'asset_id' => $t->asset_code, 'quantity' => (string) $t->quantity, 'price' => (string) $t->price, 'currency' => $t->currency, 'status' => $t->status];
});
// 3. receive our webhooks
Route::post('/planetpay/webhooks', function (Request $r) {
$ts = $r->header('X-PlanetPay-Timestamp');
abort_unless(abs(time() - (int) $ts) <= 300 && hash_equals(hash_hmac('sha256', $ts . '.' . $r->getContent(), env('PLANETPAY_WEBHOOK_SECRET')), (string) $r->header('X-PlanetPay-Signature')), 401);
ProcessPlanetPayNotification::dispatch($r->header('X-PlanetPay-Delivery'), $r->json()->all()); // deduplicate on the id
return response()->noContent();
});
Errors
All errors share one shape:
{ "error": { "code": "insufficient_balance", "message": "Insufficient balance.", "required": 10.8 } }
| HTTP | code |
Cause |
|---|---|---|
| 401 | unauthorized |
missing headers, unknown key or invalid signature |
| 401 | timestamp_expired |
X-Timestamp off by more than 5 minutes |
| 403 | kyc_required |
production key on an unverified account |
| 403 | account_disabled |
account suspended |
| 402 | insufficient_balance |
balance too low (transfer + fees, service, number, card) |
| 404 | not_found |
unknown operation |
| 409 | wallet_missing |
no USD wallet |
| 422 | validation_error |
invalid field (fields details each one) |
| 422 | currency_not_supported |
currency not available |
| 422 | operator_not_supported |
network or country not available for a transfer |
| 422 | invalid_phone |
invalid number |
| 429 | rate_limited |
more than 120 requests per minute |
| 422 | order_refused |
service order refused by the supplier (nothing is debited) |
| 422 | card_refused |
card operation refused (plan, card limit, amount…); the message gives the reason |
| 502 | card_failed |
the card could not be created by the issuer |
| 404 | not_found |
unknown service, number or card |
| 409 | number_unavailable |
no number available for this service and country (nothing debited) |
| 409 | already_completed |
the number already received its SMS: it cannot be cancelled |
| 503 | service_unavailable |
virtual numbers temporarily unavailable |
Full examples
The examples create a 20 USD recharge paid in XOF. Replace PK and SK with your keys.
cURL (bash)
PK="your_api_key"; SK="your_secret_key"
BODY='{"amount":20,"currency":"XOF","reference":"ORDER-1001","callback_url":"https://yoursite.com/planetpay/webhook"}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.POST./v1/recharge.$BODY" | openssl dgst -sha256 -hmac "$SK" | sed 's/^.* //')
curl -s https://planetpay.site/v1/recharge \
-H "Content-Type: application/json" -H "X-Api-Key: $PK" -H "X-Timestamp: $TS" -H "X-Signature: $SIG" \
-d "$BODY"
JavaScript (Node.js 18+)
import crypto from 'node:crypto';
const PK = process.env.PLANETPAY_KEY, SK = process.env.PLANETPAY_SECRET;
async function planetpay(method, path, body) {
const raw = body ? JSON.stringify(body) : '';
const ts = Math.floor(Date.now() / 1000).toString();
const sig = crypto.createHmac('sha256', SK).update(`${ts}.${method}.${path.split('?')[0]}.${raw}`).digest('hex');
const res = await fetch('https://planetpay.site' + path, {
method,
headers: { 'Content-Type': 'application/json', 'X-Api-Key': PK, 'X-Timestamp': ts, 'X-Signature': sig },
body: raw || undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
return data;
}
const recharge = await planetpay('POST', '/v1/recharge', {
amount: 20, currency: 'XOF', reference: 'ORDER-1001',
callback_url: 'https://yoursite.com/planetpay/webhook',
});
console.log(recharge.payment_url);
// Webhook (Express): verify the signature on the RAW body
// app.post('/planetpay/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// const expected = crypto.createHmac('sha256', SK)
// .update(`${req.get('X-PlanetPay-Timestamp')}.${req.body}`).digest('hex');
// if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.get('X-PlanetPay-Signature') || ''))) return res.sendStatus(401);
// const event = JSON.parse(req.body);
// if (event.event === 'recharge.completed') { /* fulfil order event.data.reference */ }
// res.sendStatus(200);
// });
PHP
<?php
const PK = 'your_api_key';
const SK = 'your_secret_key';
function planetpay(string $method, string $path, ?array $body = null): array {
$raw = $body ? json_encode($body) : '';
$ts = (string) time();
$sig = hash_hmac('sha256', "$ts.$method." . strtok($path, '?') . ".$raw", SK);
$ch = curl_init('https://planetpay.site' . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_POSTFIELDS => $raw ?: null,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Api-Key: ' . PK, "X-Timestamp: $ts", "X-Signature: $sig"],
]);
$data = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 400) throw new RuntimeException($data['error']['code'] . ': ' . $data['error']['message']);
return $data;
}
$transfer = planetpay('POST', '/v1/transfer', [
'amount' => 10, 'country' => 'SN', 'operator' => 'WAVE', 'phone' => '770000000', 'reference' => 'PAYOUT-77',
]);
echo $transfer['status'];
// Webhook: verify.php
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', ($_SERVER['HTTP_X_PLANETPAY_TIMESTAMP'] ?? '') . '.' . $raw, SK);
if (!hash_equals($expected, $_SERVER['HTTP_X_PLANETPAY_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
$event = json_decode($raw, true);
// ... handle $event['event'] and $event['data']['reference']
http_response_code(200);
Python 3
import hashlib, hmac, json, time, requests
PK, SK = "your_api_key", "your_secret_key"
def planetpay(method, path, body=None):
raw = json.dumps(body, separators=(",", ":")) if body else ""
ts = str(int(time.time()))
sig = hmac.new(SK.encode(), f"{ts}.{method}.{path.split('?')[0]}.{raw}".encode(), hashlib.sha256).hexdigest()
r = requests.request(method, "https://planetpay.site" + path, data=raw or None, timeout=30, headers={
"Content-Type": "application/json", "X-Api-Key": PK, "X-Timestamp": ts, "X-Signature": sig})
data = r.json()
if r.status_code >= 400:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
return data
print(planetpay("GET", "/v1/balance"))
op = planetpay("POST", "/v1/recharge", {"amount": 20, "currency": "XOF", "reference": "ORDER-1001"})
print(op["payment_url"])
# Webhook (Flask):
# @app.post("/planetpay/webhook")
# def webhook():
# raw = request.get_data()
# expected = hmac.new(SK.encode(), request.headers["X-PlanetPay-Timestamp"].encode() + b"." + raw, hashlib.sha256).hexdigest()
# if not hmac.compare_digest(expected, request.headers.get("X-PlanetPay-Signature", "")):
# return "", 401
# event = json.loads(raw)
# return "", 200
Digital services, virtual numbers and Visa cards: PHP, Python, Node.js, Ruby, cURL
Each example lists tool rentals and orders one, creates a Visa card and funds it with 25 USD, then buys a WhatsApp virtual number and waits for the SMS code. Run them first with a test key: nothing is debited. Keys are read from the PLANETPAY_KEY and PLANETPAY_SECRET environment variables.
The signed path never includes the query string: for
GET /v1/services?category=rental, sign/v1/services.
PHP
<?php
define('PK', getenv('PLANETPAY_KEY'));
define('SK', getenv('PLANETPAY_SECRET'));
function planetpay(string $method, string $path, ?array $body = null): array {
$raw = $body ? json_encode($body) : '';
$ts = (string) time();
$sig = hash_hmac('sha256', "$ts.$method." . strtok($path, '?') . ".$raw", SK);
$ch = curl_init('https://planetpay.site' . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_POSTFIELDS => $raw ?: null,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Api-Key: ' . PK, "X-Timestamp: $ts", "X-Signature: $sig"],
]);
$data = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 400) throw new RuntimeException($data['error']['code'] . ': ' . $data['error']['message']);
return $data;
}
// 1. Tool rental: list, then order
$rentals = planetpay('GET', '/v1/services?category=rental&q=unlock');
$service = $rentals['data'][0];
echo $service['name'], ' — ', $service['price'], " USD\n";
$order = planetpay('POST', '/v1/services/' . $service['id'] . '/orders', [
'identifier' => 'my-unlocktool-username',
'reference' => 'RENT-' . time(),
'callback_url' => 'https://example.com/planetpay/webhook',
]);
echo $order['status'], ' ', $order['result'] ?? '(pending: wait for the webhook)', "\n";
// 2. Visa card: create, then fund with 25 USD
$card = planetpay('POST', '/v1/cards', ['name_on_card' => 'Awa Traore', 'brand' => 'visa', 'reference' => 'CARD-' . time()]);
$cardId = $card['card']['id'];
$fund = planetpay('POST', "/v1/cards/$cardId/fund", ['amount' => 25, 'reference' => 'FUND-' . time()]);
echo $cardId, ' ', $fund['status'], ' balance ', $fund['card']['balance'], "\n";
// 3. Virtual number: cheapest country for WhatsApp, buy, then wait for the SMS code
$country = planetpay('GET', '/v1/numbers/services/whatsapp/countries')['data'][0]['country'];
$num = planetpay('POST', '/v1/numbers', ['service' => 'whatsapp', 'country' => $country, 'reference' => 'OTP-' . time()]);
echo 'Give this number to your customer: ', $num['number'], "\n";
while ($num['status'] === 'pending') { sleep(5); $num = planetpay('GET', '/v1/numbers/' . $num['id']); }
echo $num['status'] === 'completed' ? 'Code: ' . $num['code'] : 'No SMS, refunded', "\n";
Python 3
import hashlib, hmac, json, os, time, requests
PK, SK = os.environ["PLANETPAY_KEY"], os.environ["PLANETPAY_SECRET"]
def planetpay(method, path, body=None):
raw = json.dumps(body, separators=(",", ":")) if body else ""
ts = str(int(time.time()))
signed = path.split("?")[0]
sig = hmac.new(SK.encode(), f"{ts}.{method}.{signed}.{raw}".encode(), hashlib.sha256).hexdigest()
r = requests.request(method, "https://planetpay.site" + path, data=raw or None, timeout=30, headers={
"Content-Type": "application/json", "X-Api-Key": PK, "X-Timestamp": ts, "X-Signature": sig})
data = r.json()
if r.status_code >= 400:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
return data
# 1. Tool rental: list, then order
service = planetpay("GET", "/v1/services?category=rental&q=unlock")["data"][0]
print(service["name"], "-", service["price"], "USD")
order = planetpay("POST", f"/v1/services/{service['id']}/orders", {
"identifier": "my-unlocktool-username", "reference": f"RENT-{int(time.time())}"})
print(order["status"], order.get("result", "(pending: wait for the webhook)"))
# 2. Visa card: create, then fund with 25 USD
card = planetpay("POST", "/v1/cards", {"name_on_card": "Awa Traore", "brand": "visa", "reference": f"CARD-{int(time.time())}"})
fund = planetpay("POST", f"/v1/cards/{card['card']['id']}/fund", {"amount": 25, "reference": f"FUND-{int(time.time())}"})
print(card["card"]["id"], fund["status"], "balance", fund["card"]["balance"])
# 3. Virtual number: cheapest country for WhatsApp, buy, then wait for the SMS code
country = planetpay("GET", "/v1/numbers/services/whatsapp/countries")["data"][0]["country"]
num = planetpay("POST", "/v1/numbers", {"service": "whatsapp", "country": country, "reference": f"OTP-{int(time.time())}"})
print("Give this number to your customer:", num["number"])
while num["status"] == "pending":
time.sleep(5); num = planetpay("GET", f"/v1/numbers/{num['id']}")
print("Code:", num["code"] if num["status"] == "completed" else "no SMS, refunded")
Node.js 18+
import crypto from 'node:crypto';
const PK = process.env.PLANETPAY_KEY, SK = process.env.PLANETPAY_SECRET;
async function planetpay(method, path, body) {
const raw = body ? JSON.stringify(body) : '';
const ts = Math.floor(Date.now() / 1000).toString();
const signed = path.split('?')[0];
const sig = crypto.createHmac('sha256', SK).update(`${ts}.${method}.${signed}.${raw}`).digest('hex');
const res = await fetch('https://planetpay.site' + path, {
method,
headers: { 'Content-Type': 'application/json', 'X-Api-Key': PK, 'X-Timestamp': ts, 'X-Signature': sig },
body: raw || undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
return data;
}
// 1. Tool rental: list, then order
const { data: rentals } = await planetpay('GET', '/v1/services?category=rental&q=unlock');
const order = await planetpay('POST', `/v1/services/${rentals[0].id}/orders`, {
identifier: 'my-unlocktool-username', reference: `RENT-${Date.now()}`,
});
console.log(rentals[0].name, order.status, order.result ?? '(pending: wait for the webhook)');
// 2. Visa card: create, then fund with 25 USD
const card = await planetpay('POST', '/v1/cards', { name_on_card: 'Awa Traore', brand: 'visa', reference: `CARD-${Date.now()}` });
const fund = await planetpay('POST', `/v1/cards/${card.card.id}/fund`, { amount: 25, reference: `FUND-${Date.now()}` });
console.log(card.card.id, fund.status, 'balance', fund.card.balance);
// 3. Virtual number: cheapest country for WhatsApp, buy, then wait for the SMS code
const { data: countries } = await planetpay('GET', '/v1/numbers/services/whatsapp/countries');
let num = await planetpay('POST', '/v1/numbers', { service: 'whatsapp', country: countries[0].country, reference: `OTP-${Date.now()}` });
console.log('Give this number to your customer:', num.number);
while (num.status === 'pending') { await new Promise(r => setTimeout(r, 5000)); num = await planetpay('GET', `/v1/numbers/${num.id}`); }
console.log(num.status === 'completed' ? `Code: ${num.code}` : 'No SMS, refunded');
Ruby
require 'json'
require 'net/http'
require 'openssl'
PK = ENV.fetch('PLANETPAY_KEY')
SK = ENV.fetch('PLANETPAY_SECRET')
def planetpay(method, path, body = nil)
raw = body ? JSON.generate(body) : ''
ts = Time.now.to_i.to_s
sig = OpenSSL::HMAC.hexdigest('SHA256', SK, "#{ts}.#{method}.#{path.split('?').first}.#{raw}")
uri = URI("https://planetpay.site#{path}")
req = Net::HTTPGenericRequest.new(method, !raw.empty?, true, uri.request_uri,
'Content-Type' => 'application/json', 'X-Api-Key' => PK, 'X-Timestamp' => ts, 'X-Signature' => sig)
req.body = raw unless raw.empty?
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)
raise "#{data['error']['code']}: #{data['error']['message']}" if res.code.to_i >= 400
data
end
# 1. Tool rental: list, then order
service = planetpay('GET', '/v1/services?category=rental&q=unlock')['data'].first
order = planetpay('POST', "/v1/services/#{service['id']}/orders",
{ identifier: 'my-unlocktool-username', reference: "RENT-#{Time.now.to_i}" })
puts "#{service['name']} #{order['status']} #{order['result'] || '(pending: wait for the webhook)'}"
# 2. Visa card: create, then fund with 25 USD
card = planetpay('POST', '/v1/cards', { name_on_card: 'Awa Traore', brand: 'visa', reference: "CARD-#{Time.now.to_i}" })
fund = planetpay('POST', "/v1/cards/#{card['card']['id']}/fund", { amount: 25, reference: "FUND-#{Time.now.to_i}" })
puts "#{card['card']['id']} #{fund['status']} balance #{fund['card']['balance']}"
# 3. Virtual number: cheapest country for WhatsApp, buy, then wait for the SMS code
country = planetpay('GET', '/v1/numbers/services/whatsapp/countries')['data'][0]['country']
num = planetpay('POST', '/v1/numbers', { service: 'whatsapp', country: country, reference: "OTP-#{Time.now.to_i}" })
puts "Give this number to your customer: #{num['number']}"
while num['status'] == 'pending'
sleep 5
num = planetpay('GET', "/v1/numbers/#{num['id']}")
end
puts num['status'] == 'completed' ? "Code: #{num['code']}" : 'No SMS, refunded'
cURL (bash)
PK="$PLANETPAY_KEY"; SK="$PLANETPAY_SECRET"
pp() { # pp METHOD PATH [JSON]
local ts sig; ts=$(date +%s)
sig=$(printf '%s' "$ts.$1.${2%%\?*}.$3" | openssl dgst -sha256 -hmac "$SK" | sed 's/^.* //')
curl -s "https://planetpay.site$2" -X "$1" -H "Content-Type: application/json" \
-H "X-Api-Key: $PK" -H "X-Timestamp: $ts" -H "X-Signature: $sig" ${3:+-d "$3"}
}
pp GET "/v1/services?category=rental&limit=5"
pp POST /v1/services/130/orders '{"identifier":"my-unlocktool-username","reference":"RENT-1001"}'
pp POST /v1/cards '{"name_on_card":"Awa Traore","brand":"visa","reference":"CARD-1001"}'
pp GET /v1/numbers/services/whatsapp/countries
pp POST /v1/numbers '{"service":"whatsapp","country":"france","reference":"OTP-1001"}'
pp GET /v1/numbers/num_xxxxxxxxxxxxxxxxxxxx # until "status":"completed", then read "code"
Technical questions: contact support from your PlanetPay account, quoting the operation ID (rch_…, trf_…, ord_…, num_…, crd_……).
