PlanetPay

API PlanetPay — v1

L'API PlanetPay permet à votre site ou application, depuis votre solde PlanetPay :

Sommaire

  1. Démarrage rapide
  2. Authentification
  3. Mode test
  4. Points d'accès
  5. Services numériques (licences, location d'outils, crédits)
  6. Numéros virtuels (codes SMS)
  7. Cartes virtuelles Visa
  8. Paiement intégré (iframe)
  9. Plugin SMM panel
  10. Webhooks
  11. API partenaires (plateformes connectées)
  12. Erreurs
  13. Exemples complets

Démarrage rapide

  1. Connectez-vous à votre espace PlanetPay, menu API Credentials, et créez une clé. Vous obtenez une clé API (publique) et une clé secrète (à garder sur votre serveur, jamais dans une application mobile ou du JavaScript côté navigateur).
  2. Les nouvelles clés sont en mode test. Testez votre intégration, puis basculez la clé en production (vérification d'identité requise).
  3. Encaissez : POST /v1/recharge renvoie un payment_url où vous redirigez votre client. Votre solde est crédité dès la confirmation, et nous appelons votre callback_url.

Authentification

Chaque requête porte trois en-têtes :

En-tête Valeur
X-Api-Key votre clé API
X-Timestamp l'heure actuelle en secondes Unix (tolérance ± 5 minutes)
X-Signature HMAC-SHA256(clé_secrète, chaîne_à_signer) en hexadécimal minuscule

La chaîne à signer est :

{X-Timestamp}.{MÉTHODE}.{chemin}.{corps_brut}

Exemple pour GET /v1/balance à 1792180000 : 1792180000.GET./v1/balance.

La clé secrète ne circule jamais sur le réseau, et une requête interceptée ne peut pas être rejouée après 5 minutes.

Limite : 120 requêtes par minute et par clé (réponse 429 au-delà).


Mode test

Une clé en mode test simule tout, sans aucun mouvement d'argent :


Points d'accès

GET /v1/account

Informations du compte.

{
  "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 — réseaux pour les transferts

Réseaux Mobile Money vers lesquels vous pouvez envoyer de l'argent avec /v1/transfer. Cette liste ne concerne pas les encaissements : /v1/recharge ne demande aucun opérateur.

{
  "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 — encaisser un paiement

Crée une demande de paiement. Redirigez votre client vers payment_url (ou ouvrez-la en iframe) : il paie en Mobile Money ou par carte sur la page sécurisée PlanetPay. Vous n'avez aucun opérateur à choisir ni à transmettre : il suffit d'indiquer la devise, la page propose automatiquement les moyens de paiement du pays du client. Votre solde USD est crédité de amount dès la confirmation.

Champ Type Obligatoire Description
amount nombre oui montant à créditer, en USD. Pas de limite de montant
currency texte oui devise de paiement du client : XOF, XAF, GHS, GNF, KES, NGN, UGX, CDF, RWF, SLE, TZS, ZMW (Mobile Money) ou USD (carte internationale)
reference texte oui votre référence unique (lettres, chiffres, -_., 100 max.)
callback_url URL https non adresse appelée à la confirmation (voir Webhooks)
return_url URL https non page où revient le client après paiement

Réponse 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 est ce que paie votre client dans sa devise (frais de la méthode compris). Le lien expire après 24 h.

POST /v1/transfer — envoyer de l'argent en Mobile Money

Débite votre solde et envoie l'argent sur un compte Mobile Money.

Champ Type Obligatoire Description
amount nombre oui montant en USD reçu (converti en devise locale)
country texte oui code pays ISO à 2 lettres (CI, SN, CM…)
operator texte oui code opérateur de /v1/operators (ORANGE, MTN, WAVE…)
phone texte oui numéro du bénéficiaire, format local ou international
reference texte oui votre référence unique
callback_url URL https non adresse appelée à la confirmation

Réponse 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"
}

Si l'envoi échoue, le montant débité est remboursé automatiquement sur votre solde et le statut passe à failed.

GET /v1/transactions/{id}

Renvoie l'opération (rch_…, trf_…, ord_…, num_…, crd_…, cfd_… ou cwd_…) avec son statut à jour.

Statuts

Statut Signification
pending en attente de confirmation
completed confirmé : solde crédité (recharge) ou argent reçu (transfert)
failed échec ; pour un transfert, votre solde a été remboursé

Idempotence

La reference est unique par type d'opération et par mode. Renvoyer une requête avec la même référence ne crée pas de doublon : vous recevez l'opération existante (code 200 au lieu de 201). En cas de doute (coupure réseau), renvoyez simplement la même requête.


Services numériques

Revendez sur votre site les licences et activations de logiciels GSM, les locations d'outils à l'heure (UnlockTool, Chimera, TSM, AMT…), les crédits et les services IMEI / numéro de série du catalogue PlanetPay.

Fonctionnement

  1. GET /v1/services : vous lisez le catalogue (prix en USD, déjà définitifs).
  2. POST /v1/services/{id}/orders : vous commandez pour votre client ; le prix est débité de votre solde USD.
  3. Le résultat (code, identifiants, activation) arrive dans la réponse, ou plus tard par webhook.
  4. Si le fournisseur refuse ou échoue, votre solde est remboursé automatiquement (refunded: true).

GET /v1/services — catalogue

Paramètre Description
category rental (location d'outils), licence (licences et crédits), imei (services IMEI / SN)
q recherche dans le nom et l'outil
page, limit pagination (limit : 50 par défaut, 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
  }]
}
Champ Description
price prix unitaire débité, en USD
requires champs à envoyer à la commande : identifier toujours, les autres dans fields
quantity { "min": 1, "max": 100 } pour les services vendus à la quantité (crédits), sinon null
delivery_time délai indicatif annoncé par le fournisseur

GET /v1/services/{id} — un service

Même format qu'un élément de la liste. 404 si le service n'existe pas ou n'est plus vendu.

POST /v1/services/{id}/orders — commander

Champ Type Obligatoire Description
identifier texte (120 max.) oui identifiant principal : nom d'utilisateur de l'outil, IMEI, numéro de série…
fields objet selon requires autres champs, par exemple {"email": "client@mail.com"}
quantity entier non services vendus à la quantité (borné à min / max)
reference texte oui votre référence unique (idempotence)
callback_url URL https non appelée à la livraison ou au remboursement

Réponse 201 :

{
  "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 Signification
pending commande transmise au fournisseur ; le résultat arrive par le webhook service_order.completed
completed livrée : le résultat est dans result
failed refusée ou échouée : error donne la raison, refunded vaut true (solde recrédité)

Suivi : GET /v1/transactions/{id} avec l'identifiant ord_….


Numéros virtuels

Achetez un numéro de téléphone temporaire pour recevoir un code de vérification par SMS (WhatsApp, Telegram, Google, Facebook, TikTok…), dans le pays de votre choix. Le prix est débité de votre solde USD au moment où le numéro est attribué.

Si aucun SMS n'arrive avant l'expiration du numéro (environ 20 minutes), ou si vous annulez, la commande est remboursée automatiquement.

Fonctionnement

  1. GET /v1/numbers/services : la liste des services (applications).
  2. GET /v1/numbers/services/{service}/countries : les pays disponibles pour ce service, avec prix et stock.
  3. POST /v1/numbers : vous achetez un numéro et le donnez à votre client.
  4. Le code arrive par le webhook number.completed, ou en interrogeant GET /v1/numbers/{id} toutes les 5 à 10 secondes.

GET /v1/numbers/services — services

{
  "object": "list", "total": 2,
  "data": [
    { "service": "telegram", "name": "Telegram" },
    { "service": "whatsapp", "name": "WhatsApp" }
  ]
}

Utilisez la valeur service dans les appels suivants.

GET /v1/numbers/services/{service}/countries — pays et prix

{
  "object": "list", "service": "whatsapp",
  "data": [
    { "country": "usa", "name": "États-Unis", "price": 0.31, "currency": "USD", "stock": 340 },
    { "country": "france", "name": "France", "price": 0.61, "currency": "USD", "stock": 12 }
  ]
}

Les pays sont triés du moins cher au plus cher. price est le prix final débité ; stock le nombre de numéros disponibles au moment de la lecture. Les prix et le stock bougent : relisez-les juste avant d'acheter.

POST /v1/numbers — acheter un numéro

Champ Type Obligatoire Description
service texte oui valeur service de /v1/numbers/services (par exemple whatsapp)
country texte oui valeur country de /v1/numbers/services/{service}/countries (par exemple france)
reference texte oui votre référence unique (idempotence)
callback_url URL https non appelée à la réception du SMS ou au remboursement

Réponse 201 :

{
  "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} — état et code reçu

Renvoie le numéro avec son état à jour (PlanetPay interroge le fournisseur à chaque appel, au plus une fois toutes les 5 secondes). Quand le SMS est arrivé :

{ "id": "num_hejqeog9olf0ufukhzii", "status": "completed", "sms": "Your WhatsApp code: 482-913", "code": "482913", "…": "…" }
status Signification
pending numéro attribué, en attente du SMS
completed SMS reçu : texte complet dans sms, code extrait dans code
failed aucun SMS avant expires_at, ou annulé : refunded vaut true, reason donne la cause

GET /v1/transactions/{id} renvoie la même chose.

POST /v1/numbers/{id}/cancel — annuler

Annule un numéro qui n'a pas encore reçu de SMS ; votre solde est recrédité tout de suite (status: failed, refunded: true). Un numéro qui a déjà reçu son SMS ne peut plus être annulé (409 already_completed).

Conseils : achetez le numéro seulement quand votre client est prêt à saisir le code ; un numéro sert à un seul code ; si le service refuse le numéro, annulez et rachetez dans un autre pays.


Cartes virtuelles Visa

Créez des cartes virtuelles Visa ou Mastercard en USD pour vous ou vos clients, rechargez-les et retirez leur solde, depuis votre solde PlanetPay. Les cartes fonctionnent pour les paiements en ligne (abonnements, publicité, e-commerce…).

Conditions (identiques à l'application PlanetPay) : compte vérifié (KYC), profil de titulaire de carte avec numéro de téléphone, forfait carte actif, nombre maximum de cartes de votre forfait. Les frais d'émission, de recharge et de retrait de votre forfait sont débités de votre solde USD et visibles dans votre espace.

Sécurité : l'API ne renvoie jamais le numéro complet ni le CVV, seulement les 4 derniers chiffres (last4). Le titulaire consulte les données complètes dans son espace PlanetPay, après authentification.

Fonctionnement

  1. POST /v1/cards : création de la carte (frais d'émission débités).
  2. POST /v1/cards/{id}/fund : chargement de la carte depuis votre solde.
  3. Paiements avec la carte.
  4. POST /v1/cards/{id}/withdraw : retour du solde restant de la carte vers votre solde.

L'objet carte

{
  "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"
}
Champ Description
id identifiant de la carte (card_…), à utiliser dans les autres appels
name nom imprimé sur la carte
brand Visa ou Mastercard
last4, expiry 4 derniers chiffres, date d'expiration MM/AA
balance solde de la carte en USD
status enabled = utilisable ; toute autre valeur = carte bloquée ou fermée

GET /v1/cards — vos cartes

{ "object": "list", "mode": "live", "data": [ { "id": "card_…", "object": "card", "…": "…" } ] }

Avec une clé de test, la liste est vide : les cartes de test n'existent que dans les réponses de POST /v1/cards.

GET /v1/cards/{id} — une carte

La carte, avec son solde, ses 4 derniers chiffres et son expiration lus en direct chez l'émetteur.

POST /v1/cards — créer une carte

Champ Type Obligatoire Description
name_on_card texte (4 à 50) oui nom imprimé sur la carte
brand visa | mastercard non visa par défaut
reference texte oui votre référence unique (idempotence)
callback_url URL https non adresse de notification

Réponse 201 :

{
  "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 — recharger

Champ Type Obligatoire Description
amount nombre (USD, 1 à 100 000) oui montant chargé sur la carte (les frais de recharge s'ajoutent)
reference texte oui votre référence unique
callback_url URL https non adresse de notification

Réponse 201 (cfd_…) avec amount et la carte à jour dans card.

POST /v1/cards/{id}/withdraw — retirer vers votre solde

Mêmes champs que la recharge. Le montant revient sur votre solde USD (les frais de retrait de votre forfait s'appliquent). Réponse 201 (cwd_…).

Erreurs propres aux cartes

HTTP code Cause
402 insufficient_balance solde PlanetPay (ou solde de la carte, pour un retrait) insuffisant
403 kyc_required clé de production sur un compte non vérifié
404 not_found carte inconnue ou appartenant à un autre compte
422 card_refused règle du forfait non respectée (pas de forfait, limite de cartes, profil titulaire ou téléphone manquant, montant…) ; message donne la raison
502 card_failed l'émetteur n'a pas pu créer la carte ; vérifiez votre historique puis réessayez plus tard

Paiement intégré (iframe)

Votre client paie sans quitter votre site : la fenêtre PlanetPay s'ouvre par-dessus votre page (plein écran sur mobile).

👉 Essayer la démo interactive

<script src="https://planetpay.site/js/planetpay.js"></script>
<button id="pay">Payer</button>
<script>
document.getElementById('pay').onclick = async function () {
  // Votre serveur appelle POST /v1/recharge et renvoie payment_url (ne mettez jamais la clé secrète dans le navigateur)
  const { payment_url } = await fetch('/ma-route/creer-paiement', { method: 'POST' }).then(r => r.json());
  PlanetPay.open(payment_url, {
    onSuccess: function (e) { /* afficher « merci » ; la livraison attend le webhook */ },
    onFailure: function (e) { /* proposer de réessayer */ },
    onClose:   function (e) { }
  });
};
</script>
Option Rôle
onSuccess(e) le client a payé (e.reference, e.mode)
onFailure(e) le paiement a échoué
onClose(e) la fenêtre a été fermée
autoClose fermer automatiquement après succès (true par défaut, délai autoCloseDelay = 2500 ms)
closeOnBackdrop fermer en cliquant à côté (true par défaut)

Sans JavaScript, utilisez une simple redirection vers payment_url (avec return_url) ou une balise <iframe src="payment_url" allow="payment *">. La page de paiement envoie alors à votre page des messages postMessage de la forme { "source": "planetpay", "event": "ready" | "success" | "failed" | "close", "reference": "…" } ; vérifiez que event.origin vaut https://planetpay.site.

Important : l'événement du navigateur sert à l'affichage. Ne livrez une commande qu'après le webhook signé ou GET /v1/transactions/{id}.


Plugin SMM panel

Vous avez un SMM panel (PHP + MySQL) ? Vos clients rechargent leur solde par Mobile Money ou carte, dans une fenêtre PlanetPay intégrée au panel, et le solde est crédité automatiquement.

📦 Télécharger le plugin (version 2.1, PHP 7.4+, sans dépendance)

  1. Déposez le dossier planetpay dans le dossier public du panel (https://votre-panel.com/planetpay/).
  2. Exécutez install.sql dans la base du panel.
  3. Copiez config.sample.php en config.php (base et mot de passe), puis collez vos clés API dans planetpay/settings.php. Réglez aussi la base, la table et la colonne du solde (souvent users / balance), la clé de session du client connecté.
  4. Sur la page « Ajouter des fonds » : <?php include $_SERVER['DOCUMENT_ROOT'] . '/planetpay/widget.php'; ?>
  5. Ajoutez la tâche cron de secours toutes les 5 minutes : php /chemin/planetpay/cron.php (crédite les paiements dont le webhook a été manqué).
  6. Testez avec une clé de test, connecté avec un compte listé dans test_users (« Simuler un paiement réussi »), puis passez en production. Les paiements de test ne sont jamais crédités aux autres clients.

Le plugin identifie le client par la session du panel, vérifie la signature du webhook et l'opération auprès de l'API, puis crédite une seule fois le montant réellement payé. Le mode d'emploi complet est dans README.md.


Webhooks

Quand une opération est confirmée ou échoue, PlanetPay envoie un POST JSON à votre callback_url :

{
  "event": "recharge.completed",
  "data": { "id": "rch_…", "object": "recharge", "reference": "ORDER-1001", "status": "completed", "…": "…" }
}

Événements : recharge.completed, recharge.failed, transfer.completed, transfer.failed, service_order.completed, service_order.failed, number.completed (SMS reçu, code dans data.code), number.failed (numéro remboursé).

Vérifiez toujours la signature avant de livrer une commande :

En-tête Valeur
X-PlanetPay-Timestamp heure d'envoi (secondes Unix)
X-PlanetPay-Signature HMAC-SHA256(clé_secrète, timestamp + "." + corps_brut)

Répondez avec un code 2xx. Sinon, nous réessayons 5 fois (après 10 s, 1 min, 5 min, 15 min et 1 h). Seules les adresses https publiques sont appelées.


API partenaires (plateformes connectées)

Cette API sert aux entreprises qui ont déjà une plateforme en production (bourse, investissement, paiement) et qui veulent transmettre leurs transactions réelles à PlanetPay pour qu'elles apparaissent sur le marché (cours, volumes, bougies). Ce n'est pas un dépôt de projets : seules les données vérifiables sont publiées.

Connexion en 4 étapes

  1. Demande : dans votre espace PlanetPay, menu Connecter ma plateforme (entreprise, site HTTPS, conformité, URL de votre API d'audit).
  2. Preuve du domaine : enregistrement DNS TXT planetpay-verify=<jeton> (sur @ ou _planetpay), lu via Google DNS-over-HTTPS, ou fichier https://<domaine>/.well-known/planetpay-verify.txt contenant le jeton. Vérifié automatiquement toutes les heures puis chaque jour ; preuve absente 3 fois de suite = connexion suspendue.
  3. Vérification et phase de test : l'équipe PlanetPay contrôle la conformité et ouvre la phase de test (clés pk_test_…). L'approbation exige un domaine vérifié et une API d'audit qui a répondu au moins une fois.
  4. Production : clés pk_live_…, transactions réelles, double vérification de chaque transaction.

Le secret d'une clé (psk_…) n'est affiché qu'une seule fois ; il reste chiffré chez nous. Deux clés actives maximum par mode (rotation : créez la nouvelle, basculez, révoquez l'ancienne).

Authentification partenaire

Base : https://planetpay.site/v1/partner (aussi accessible sous /api/v1/partner). HTTPS uniquement, JSON UTF-8, 64 Ko max.

En-tête Valeur
X-Partner-Key (ou X-Partner-Id) pk_test_… / pk_live_…
X-Timestamp (ou X-Partner-Timestamp) heure Unix en secondes (±5 min)
X-Signature (ou X-Partner-Signature) hex(HMAC-SHA256(secret, timestamp + "." + MÉTHODE + "." + chemin + "." + corps_brut))

chemin = chemin appelé sans domaine ni paramètres (/v1/partner/events). Une signature n'est acceptée qu'une fois (anti-rejeu) : signez à nouveau chaque requête, même pour une nouvelle tentative. Limite : 300 requêtes/min par clé.

Points d'accès

Méthode Chemin Usage
GET /v1/partner/health état du service, sans authentification
GET /v1/partner/status vérification de connexion : partenaire, statut, mode, domaine, mode réel autorisé
POST /v1/partner/events envoyer un événement → 202 accepté, 200 doublon, 409 conflit, 422 invalide
GET /v1/partner/events/{event_id} statut d'un événement envoyé
GET /v1/partner/events?status=&type=&limit=&before= liste (plus récents d'abord, curseur next_before)
GET /v1/partner/assets vos actifs connus de PlanetPay : statut (listé ou non), dernier prix vérifié, variation et volume 24 h
GET /v1/partner/transactions/{trade_id} une transaction vue par PlanetPay : conservée ou annulée, double vérification, présence dans les cours, commissions
GET /v1/partner/commissions?status=&limit=&before= lignes de commission (plus récentes d'abord, curseur next_before)
GET /v1/partner/reports?from=AAAA-MM-JJ&to=AAAA-MM-JJ rapport de période (30 jours par défaut, 366 max) : événements, transactions, volume par actif, commissions, relevés, règle tarifaire

Format d'un événement

{
  "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.ma-plateforme.com/trades/TR-88412"
  }
}
type obligatoire dans 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 pour annuler une transaction)
asset.price_updated asset_id, price, currency (prix indicatif, n'entre pas dans les bougies)

Règles : event_id unique chez vous (renvoyé à l'identique = doublon, autre contenu = 409) ; occurred_at ISO 8601 avec fuseau, ni futur ni plus de 30 jours ; décimaux positifs en chaîne (8 décimales max) ; trade_id identifie une exécution (jamais comptée deux fois). Un ordre n'est jamais traité comme une transaction exécutée. Chaque actif est propre à votre plateforme : aucun mélange avec les cours d'autres partenaires.

Double vérification (anti-fraude)

Une transaction reçue n'est pas publiée tout de suite. PlanetPay appelle votre API d'audit et ne retient la transaction que si elle existe chez vous avec le même actif, la même quantité, le même prix et la même devise, au statut settled.

GET https://api.ma-plateforme.com/planetpay/audit/transaction/{id}
X-PlanetPay-Partner:   ptn_…
X-PlanetPay-Timestamp: 1761643812
X-PlanetPay-Signature: hex(HMAC-SHA256(secret_de_la_clé, timestamp + ".GET." + chemin_et_paramètres + "."))

200 {"trade_id":"TR-88412","asset_id":"SNTS","quantity":"10","price":"25500","currency":"XOF","status":"settled"}

Webhooks partenaires (notifications de PlanetPay)

Configurez une URL HTTPS sur votre domaine (onglet Webhooks) : PlanetPay vous prévient dès qu'il se passe quelque chose, sans que vous ayez à interroger l'API.

Événement Quand
event.accepted / event.rejected un événement envoyé est accepté / rejeté (avec l'erreur)
trade.verified transaction confirmée par la double vérification, publiée
trade.mismatch / trade.failed transaction refusée / jamais confirmée
partner.status_changed statut de votre plateforme modifié
asset.status_changed un de vos actifs listé ou suspendu
key.revoked une clé API révoquée
webhook.test test envoyé depuis la console
POST https://api.ma-plateforme.com/planetpay/webhooks
X-PlanetPay-Event:     trade.verified
X-PlanetPay-Delivery:  whd_…        (identique à chaque nouvel essai : dédoublonnez dessus)
X-PlanetPay-Timestamp: 1761643815
X-PlanetPay-Signature: hex(HMAC-SHA256(whsec_…, timestamp + "." + corps_brut))

{"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}}

Répondez un code 2xx en moins de 10 s (traitez ensuite en file d'attente). Sinon : nouvel essai après 1, 5, 30 min, 2, 6, 12 et 24 h, puis échec (renvoi possible depuis la console). Le secret whsec_… est distinct de vos clés API et s'affiche une seule fois.

Commissions PlanetPay

PlanetPay prélève une commission sur chaque transaction que vous déclarez, selon votre contrat : pourcentage achat, pourcentage vente et/ou montant fixe par transaction (dans la devise de la transaction). La règle est figée sur la ligne au moment où la transaction arrive : un changement de tarif ne réécrit jamais l'historique, et un événement reçu plusieurs fois ne crée qu'une seule ligne.

Statut Signification
pending transaction pas encore confirmée par la double vérification, ou encore dans la fenêtre d'annulation (3 jours par défaut)
earned acquise
invoiced portée sur un relevé mensuel
paid relevé réglé
cancelled transaction annulée (transaction.reversed) ou non confirmée (mismatch, failed)

Les commissions réelles acquises d'un mois terminé sont regroupées sur un relevé mensuel par devise (PS…). Une transaction annulée après facturation génère une ligne adjustment négative portée sur le relevé suivant. Les commissions du mode test sont une simple simulation, jamais facturées. Détail et export CSV : onglet « Commissions » de votre tableau de bord.

GET /v1/partner/transactions/TR-88412
{"trade_id":"TR-88412","asset_id":"SNTS","side":"buy","quantity":"10","price":"25500","currency":"XOF","status":"valid",
 "double_check":{"status":"verified","attempts":1,"verified_at":"2026-10-28T09:31:02Z","error":null},"in_market_data":true,
 "commissions":[{"trade_id":"TR-88412","kind":"fee","base":"255000","rate_pct":"0.5","fixed":"10","amount":"1285","currency":"XOF","status":"pending","statement":null}]}

Erreurs partenaires

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 (enregistré comme rejeté ; renvoyez-le corrigé avec le même event_id)
429 rate_limited
503 service_unavailable

Exemple complet (PHP)

// 1. envoyer une transaction exécutée
$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. votre API d'audit (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. recevoir nos 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()); // dédoublonnez sur l'id
    return response()->noContent();
});

Erreurs

Toutes les erreurs ont la même forme :

{ "error": { "code": "insufficient_balance", "message": "Insufficient balance.", "required": 10.8 } }
HTTP code Cause
401 unauthorized en-têtes manquants, clé inconnue ou signature invalide
401 timestamp_expired X-Timestamp décalé de plus de 5 minutes
403 kyc_required clé de production sur un compte non vérifié
403 account_disabled compte suspendu
402 insufficient_balance solde insuffisant (transfert + frais, service, numéro, carte)
404 not_found opération inconnue
409 wallet_missing portefeuille USD absent
422 validation_error champ invalide (fields détaille chaque champ)
422 currency_not_supported devise non disponible
422 operator_not_supported réseau ou pays non disponible pour un transfert
422 invalid_phone numéro invalide
429 rate_limited plus de 120 requêtes par minute
422 order_refused commande de service refusée par le fournisseur (rien n'est débité)
422 card_refused opération de carte refusée (forfait, limite de cartes, montant…) ; le message en donne la raison
502 card_failed la carte n'a pas pu être créée chez l'émetteur
404 not_found service, numéro ou carte inconnu
409 number_unavailable aucun numéro disponible pour ce service et ce pays (rien n'est débité)
409 already_completed le numéro a déjà reçu son SMS : annulation impossible
503 service_unavailable numéros virtuels momentanément indisponibles

Exemples complets

Les exemples créent une recharge de 20 USD payée en XOF. Remplacez PK et SK par vos clés.

cURL (bash)

PK="votre_cle_api"; SK="votre_cle_secrete"
BODY='{"amount":20,"currency":"XOF","reference":"ORDER-1001","callback_url":"https://votresite.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://votresite.com/planetpay/webhook',
});
console.log(recharge.payment_url);

// Webhook (Express) : vérifier la signature sur le corps BRUT
// 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') { /* livrer la commande event.data.reference */ }
//   res.sendStatus(200);
// });

PHP

<?php
const PK = 'votre_cle_api';
const SK = 'votre_cle_secrete';

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);
// ... traiter $event['event'] et $event['data']['reference']
http_response_code(200);

Python 3

import hashlib, hmac, json, time, requests

PK, SK = "votre_cle_api", "votre_cle_secrete"

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

Services numériques, numéros virtuels et cartes Visa : PHP, Python, Node.js, Ruby, cURL

Chaque exemple liste les locations d'outils et en commande une, crée une carte Visa et la recharge de 25 USD, puis achète un numéro virtuel WhatsApp et attend le code SMS. Lancez-les d'abord avec une clé de test : rien n'est débité. Les clés sont lues dans les variables d'environnement PLANETPAY_KEY et PLANETPAY_SECRET.

Le chemin signé ne contient jamais les paramètres d'URL : pour GET /v1/services?category=rental, on signe /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"

Questions techniques : écrivez au support depuis votre espace PlanetPay, en indiquant l'identifiant de l'opération (rch_…, trf_…, ord_…, num_…, crd_……).