PlanetPay

PlanetPay API — v1

The PlanetPay API lets your website or app, from your PlanetPay balance:

Contents

  1. Quick start
  2. Authentication
  3. Test mode
  4. Endpoints
  5. Digital services (licences, tool rental, credits)
  6. Virtual numbers (SMS codes)
  7. Visa virtual cards
  8. Embedded checkout (iframe)
  9. SMM panel plugin
  10. Webhooks
  11. Partner API (connected platforms)
  12. Errors
  13. Full examples

Quick start

  1. 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).
  2. New keys are in test mode. Test your integration, then switch the key to production (identity verification required).
  3. Collect: POST /v1/recharge returns a payment_url to redirect your customer to. Your balance is credited on confirmation and we call your callback_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}

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:


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

  1. GET /v1/services: read the catalogue (prices in USD, final).
  2. POST /v1/services/{id}/orders: order for your customer; the price is debited from your USD balance.
  3. The result (code, credentials, activation) comes in the response, or later by webhook.
  4. 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

  1. GET /v1/numbers/services: the list of services (apps).
  2. GET /v1/numbers/services/{service}/countries: available countries for this service, with price and stock.
  3. POST /v1/numbers: buy a number and give it to your customer.
  4. The code comes with the number.completed webhook, or by polling GET /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

  1. POST /v1/cards: create the card (issuing fee debited).
  2. POST /v1/cards/{id}/fund: load the card from your balance.
  3. Pay with the card.
  4. 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).

👉 Try the interactive demo

<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)

  1. Upload the planetpay folder to the panel's public folder (https://your-panel.com/planetpay/).
  2. Run install.sql in the panel database.
  3. Copy config.sample.php to config.php: your API keys, the database, the balance table and column (often users / balance), the session key of the logged-in customer.
  4. On the "Add funds" page: <?php include $_SERVER['DOCUMENT_ROOT'] . '/planetpay/widget.php'; ?>
  5. Add the safety-net cron every 5 minutes: php /path/to/planetpay/cron.php (credits payments whose webhook was missed).
  6. 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

  1. Application: in your PlanetPay account, menu Connect my platform (company, HTTPS site, compliance, URL of your audit API).
  2. Domain proof: DNS TXT record planetpay-verify=<token> (on @ or _planetpay), read through Google DNS-over-HTTPS, or a file https://<domain>/.well-known/planetpay-verify.txt containing the token. Checked every hour, then every day; proof missing 3 times in a row = connection suspended.
  3. 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.
  4. 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"}

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_……).