PlanetPay

API de PlanetPay — v1

La API de PlanetPay permite a su sitio web o aplicación, desde su saldo PlanetPay:

Índice

  1. Inicio rápido
  2. Autenticación
  3. Modo de prueba
  4. Endpoints
  5. Servicios digitales (licencias, alquiler de herramientas, créditos)
  6. Números virtuales (códigos SMS)
  7. Tarjetas virtuales Visa
  8. Pago integrado (iframe)
  9. Plugin para SMM panel
  10. Webhooks
  11. API de socios (plataformas conectadas)
  12. Errores
  13. Ejemplos completos

Inicio rápido

  1. Inicie sesión en su cuenta PlanetPay, abra API Credentials y cree una clave. Obtendrá una clave API (pública) y una clave secreta (guárdela en su servidor, nunca en una aplicación móvil ni en JavaScript del navegador).
  2. Las claves nuevas están en modo de prueba. Pruebe su integración y luego pase la clave a producción (se requiere verificación de identidad).
  3. Cobre: POST /v1/recharge devuelve una payment_url a la que redirige a su cliente. Su saldo se acredita al confirmarse el pago y llamamos a su callback_url.

Autenticación

Cada solicitud lleva tres cabeceras:

Cabecera Valor
X-Api-Key su clave API
X-Timestamp la hora actual en segundos Unix (tolerancia ± 5 minutos)
X-Signature HMAC-SHA256(clave_secreta, cadena_a_firmar) en hexadecimal minúscula

La cadena a firmar es:

{X-Timestamp}.{MÉTODO}.{ruta}.{cuerpo_bruto}

Ejemplo para GET /v1/balance a las 1792180000: 1792180000.GET./v1/balance.

La clave secreta nunca viaja por la red y una solicitud interceptada no puede repetirse después de 5 minutos.

Límite: 120 solicitudes por minuto y por clave (429 si se supera).


Modo de prueba

Una clave de prueba lo simula todo, sin ningún movimiento de dinero:


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 — redes para transferencias

Redes de Mobile Money a las que puede enviar dinero con /v1/transfer. Esta lista no se usa para los cobros: /v1/recharge no pide ningún operador.

{ "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 — cobrar un pago

Crea una solicitud de pago. Redirija a su cliente a payment_url (o ábrala en un iframe): paga con Mobile Money o con tarjeta en la página segura de PlanetPay. No tiene que elegir ni enviar ningún operador: basta con indicar la moneda, la página ofrece automáticamente los medios de pago del país del cliente. Su saldo en USD se acredita con amount al confirmarse.

Campo Tipo Obligatorio Descripción
amount número sí importe a acreditar, en USD. Sin límite de importe
currency texto sí moneda de pago del cliente: XOF, XAF, GHS, GNF, KES, NGN, UGX, CDF, RWF, SLE, TZS, ZMW (Mobile Money) o USD (tarjeta internacional)
reference texto sí su referencia única (letras, cifras, -_., máx. 100)
callback_url URL https no se llama al confirmarse (ver Webhooks)
return_url URL https no página a la que vuelve el cliente tras pagar

Respuesta 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 es lo que paga su cliente en su moneda (comisiones del método incluidas). El enlace caduca a las 24 h.

POST /v1/transfer — enviar Mobile Money

Debita su saldo y envía el dinero a una cuenta de Mobile Money.

Campo Tipo Obligatorio Descripción
amount número sí importe en USD (convertido a la moneda local)
country texto sí código de país ISO de 2 letras (CI, SN, CM…)
operator texto sí código de operador de /v1/operators (ORANGE, MTN, WAVE…)
phone texto sí número del beneficiario, formato local o internacional
reference texto sí su referencia única
callback_url URL https no se llama al confirmarse

Respuesta 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 el envío falla, el importe debitado se reembolsa automáticamente y el estado pasa a failed.

GET /v1/transactions/{id}

Devuelve la operación (rch_…, trf_…, ord_…, num_…, crd_…, cfd_… o cwd_…) con su estado actualizado.

Estados

Estado Significado
pending pendiente de confirmación
completed confirmado: saldo acreditado (recarga) o dinero recibido (transferencia)
failed fallido; en una transferencia, su saldo ha sido reembolsado

Idempotencia

reference es única por tipo de operación y modo. Reenviar una solicitud con la misma referencia no crea duplicados: recibe la operación existente (código 200 en lugar de 201). Ante la duda (corte de red), reenvíe la misma solicitud.


Servicios digitales

Revenda en su sitio las licencias y activaciones de software GSM, los alquileres de herramientas por horas (UnlockTool, Chimera, TSM, AMT…), los créditos y los servicios IMEI / número de serie del catálogo de PlanetPay.

Funcionamiento

  1. GET /v1/services: lee el catálogo (precios en USD, definitivos).
  2. POST /v1/services/{id}/orders: pide para su cliente; el precio se descuenta de su saldo en USD.
  3. El resultado (código, credenciales, activación) llega en la respuesta, o más tarde por webhook.
  4. Si el proveedor rechaza o falla, su saldo se reembolsa automáticamente (refunded: true).

GET /v1/services — catálogo

Parámetro Descripción
category rental (alquiler de herramientas), licence (licencias y créditos), imei (servicios IMEI / SN)
q búsqueda en el nombre y la herramienta
page, limit paginación (limit: 50 por defecto, 100 máx.)
{
  "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
  }]
}
Campo Descripción
price precio unitario cobrado, en USD
requires campos que se envían con el pedido: siempre identifier, los demás en fields
quantity { "min": 1, "max": 100 } para los servicios vendidos por cantidad (créditos), si no null
delivery_time plazo orientativo anunciado por el proveedor

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

Mismo formato que un elemento de la lista. 404 si el servicio no existe o ya no se vende.

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

Campo Tipo Obligatorio Descripción
identifier texto (120 máx.) sí identificador principal: usuario de la herramienta, IMEI, número de serie…
fields objeto según requires otros campos, p. ej. {"email": "cliente@mail.com"}
quantity entero no servicios vendidos por cantidad (limitado a min / max)
reference texto sí su referencia única (idempotencia)
callback_url URL https no llamada en la entrega o el reembolso

Respuesta 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 Significado
pending pedido enviado al proveedor; el resultado llega con el webhook service_order.completed
completed entregado: el resultado está en result
failed rechazado o fallido: error indica el motivo, refunded vale true (saldo reintegrado)

Seguimiento: GET /v1/transactions/{id} con el identificador ord_….


Números virtuales

Compre un número de teléfono temporal para recibir un código de verificación por SMS (WhatsApp, Telegram, Google, Facebook, TikTok…), en el país que elija. El precio se descuenta de su saldo en USD cuando se asigna el número.

Si no llega ningún SMS antes de que caduque el número (unos 20 minutos), o si usted cancela, el pedido se reembolsa automáticamente.

Funcionamiento

  1. GET /v1/numbers/services: la lista de servicios (aplicaciones).
  2. GET /v1/numbers/services/{service}/countries: los países disponibles para ese servicio, con precio y stock.
  3. POST /v1/numbers: compra un número y se lo da a su cliente.
  4. El código llega con el webhook number.completed, o consultando GET /v1/numbers/{id} cada 5 a 10 segundos.

GET /v1/numbers/services — servicios

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

Use el valor service en las llamadas siguientes.

GET /v1/numbers/services/{service}/countries — países y precios

{
  "object": "list", "service": "whatsapp",
  "data": [
    { "country": "usa", "name": "Estados Unidos", "price": 0.31, "currency": "USD", "stock": 340 },
    { "country": "france", "name": "Francia", "price": 0.61, "currency": "USD", "stock": 12 }
  ]
}

Los países van del más barato al más caro. price es el precio final cobrado; stock, los números disponibles en el momento de la consulta. Precios y stock cambian: vuelva a leerlos justo antes de comprar.

POST /v1/numbers — comprar un número

Campo Tipo Obligatorio Descripción
service texto sí valor service de /v1/numbers/services (p. ej. whatsapp)
country texto sí valor country de /v1/numbers/services/{service}/countries (p. ej. france)
reference texto sí su referencia única (idempotencia)
callback_url URL https no llamada al recibir el SMS o en el reembolso

Respuesta 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} — estado y código recibido

Devuelve el número con su estado actualizado (PlanetPay consulta al proveedor en cada llamada, como máximo una vez cada 5 segundos). Cuando llega el SMS:

{ "id": "num_hejqeog9olf0ufukhzii", "status": "completed", "sms": "Your WhatsApp code: 482-913", "code": "482913", "…": "…" }
status Significado
pending número asignado, esperando el SMS
completed SMS recibido: texto completo en sms, código extraído en code
failed ningún SMS antes de expires_at, o cancelado: refunded vale true, reason indica la causa

GET /v1/transactions/{id} devuelve lo mismo.

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

Cancela un número que aún no ha recibido el SMS; su saldo se reintegra al instante (status: failed, refunded: true). Un número que ya recibió su SMS no se puede cancelar (409 already_completed).

Consejos: compre el número solo cuando su cliente esté listo para escribir el código; un número sirve para un solo código; si la aplicación rechaza el número, cancele y compre otro en otro país.


Tarjetas virtuales Visa

Cree tarjetas virtuales Visa o Mastercard en USD para usted o sus clientes, recárguelas y retire su saldo, desde su saldo PlanetPay. Las tarjetas sirven para pagos en línea (suscripciones, publicidad, comercio electrónico…).

Requisitos (los mismos que en la aplicación PlanetPay): cuenta verificada (KYC), perfil de titular con número de teléfono, plan de tarjetas activo, número máximo de tarjetas de su plan. Las comisiones de emisión, recarga y retiro de su plan se descuentan de su saldo en USD y aparecen en su cuenta.

Seguridad: la API nunca devuelve el número completo ni el CVV, solo los 4 últimos dígitos (last4). El titular ve los datos completos en su cuenta PlanetPay, tras autenticarse.

Funcionamiento

  1. POST /v1/cards: crea la tarjeta (se cobra la comisión de emisión).
  2. POST /v1/cards/{id}/fund: carga la tarjeta desde su saldo.
  3. Pagos con la tarjeta.
  4. POST /v1/cards/{id}/withdraw: devuelve el saldo restante de la tarjeta a su saldo.

El objeto tarjeta

{
  "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"
}
Campo Descripción
id identificador de la tarjeta (card_…), para las demás llamadas
name nombre impreso en la tarjeta
brand Visa o Mastercard
last4, expiry 4 últimos dígitos, caducidad MM/AA
balance saldo de la tarjeta en USD
status enabled = utilizable; cualquier otro valor = tarjeta bloqueada o cerrada

GET /v1/cards — sus tarjetas

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

Con una clave de prueba la lista está vacía: las tarjetas de prueba solo existen en las respuestas de POST /v1/cards.

GET /v1/cards/{id} — una tarjeta

La tarjeta, con su saldo, sus 4 últimos dígitos y su caducidad leídos en directo en el emisor.

POST /v1/cards — crear una tarjeta

Campo Tipo Obligatorio Descripción
name_on_card texto (4 a 50) sí nombre impreso en la tarjeta
brand visa | mastercard no visa por defecto
reference texto sí su referencia única (idempotencia)
callback_url URL https no dirección de notificación

Respuesta 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 — recargar

Campo Tipo Obligatorio Descripción
amount número (USD, 1 a 100 000) sí importe cargado en la tarjeta (las comisiones de recarga se suman)
reference texto sí su referencia única
callback_url URL https no dirección de notificación

Respuesta 201 (cfd_…) con amount y la tarjeta actualizada en card.

POST /v1/cards/{id}/withdraw — retirar a su saldo

Mismos campos que la recarga. El importe vuelve a su saldo en USD (se aplican las comisiones de retiro de su plan). Respuesta 201 (cwd_…).

Errores de tarjetas

HTTP code Causa
402 insufficient_balance saldo PlanetPay (o saldo de la tarjeta, en un retiro) insuficiente
403 kyc_required clave de producción en una cuenta no verificada
404 not_found tarjeta desconocida o de otra cuenta
422 card_refused regla del plan no cumplida (sin plan, límite de tarjetas, perfil de titular o teléfono ausente, importe…); message indica el motivo
502 card_failed el emisor no pudo crear la tarjeta; revise su historial y vuelva a intentarlo más tarde

Pago integrado (iframe)

Su cliente paga sin salir de su sitio: la ventana de PlanetPay se abre sobre su página (pantalla completa en móvil).

👉 Probar la demo interactiva

<script src="https://planetpay.site/js/planetpay.js"></script>
<button id="pay">Pagar</button>
<script>
document.getElementById('pay').onclick = async function () {
  // Su servidor llama a POST /v1/recharge y devuelve payment_url (nunca ponga la clave secreta en el navegador)
  const { payment_url } = await fetch('/mi-ruta/crear-pago', { method: 'POST' }).then(r => r.json());
  PlanetPay.open(payment_url, {
    onSuccess: function (e) { /* mostrar «gracias»; la entrega espera el webhook */ },
    onFailure: function (e) { /* ofrecer reintentar */ },
    onClose:   function (e) { }
  });
};
</script>
Opción Función
onSuccess(e) el cliente pagó (e.reference, e.mode)
onFailure(e) el pago falló
onClose(e) se cerró la ventana
autoClose cerrar automáticamente tras el éxito (true por defecto, autoCloseDelay = 2500 ms)
closeOnBackdrop cerrar al pulsar fuera (true por defecto)

Sin JavaScript, redirija a payment_url (con return_url) o use <iframe src="payment_url" allow="payment *">. La página de pago envía entonces a su página mensajes postMessage con la forma { "source": "planetpay", "event": "ready" | "success" | "failed" | "close", "reference": "…" }; compruebe que event.origin sea https://planetpay.site.

Importante: el evento del navegador solo sirve para la interfaz. Entregue un pedido solo tras el webhook firmado o GET /v1/transactions/{id}.


Plugin para SMM panel

¿Tiene un SMM panel (PHP + MySQL)? Sus clientes recargan su saldo con Mobile Money o tarjeta, en una ventana de PlanetPay dentro del panel, y el saldo se acredita automáticamente.

📦 Descargar el plugin (versión 2.1, PHP 7.4+, sin dependencias)

  1. Suba la carpeta planetpay a la carpeta pública del panel (https://su-panel.com/planetpay/).
  2. Ejecute install.sql en la base de datos del panel.
  3. Copie config.sample.php como config.php: sus claves API, la base de datos, la tabla y columna del saldo (a menudo users / balance), la clave de sesión del cliente conectado.
  4. En la página «Añadir fondos»: <?php include $_SERVER['DOCUMENT_ROOT'] . '/planetpay/widget.php'; ?>
  5. Añada la tarea cron de respaldo cada 5 minutos: php /ruta/planetpay/cron.php (acredita los pagos cuyo webhook se perdió).
  6. Pruebe con una clave de prueba, conectado con una cuenta incluida en test_users («Simular un pago correcto»), y luego pase a producción. Los pagos de prueba nunca se acreditan a otros clientes.

El plugin identifica al cliente por la sesión del panel, verifica la firma del webhook y la operación con la API, y acredita una sola vez el importe realmente pagado. Instrucciones completas en README.md.


Webhooks

Cuando una operación se confirma o falla, PlanetPay envía un POST JSON a su callback_url:

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

Eventos: recharge.completed, recharge.failed, transfer.completed, transfer.failed, service_order.completed, service_order.failed, number.completed (SMS recibido, código en data.code), number.failed (número reembolsado).

Verifique siempre la firma antes de entregar un pedido:

Cabecera Valor
X-PlanetPay-Timestamp hora de envío (segundos Unix)
X-PlanetPay-Signature HMAC-SHA256(clave_secreta, timestamp + "." + cuerpo_bruto)

Responda con un código 2xx. Si no, reintentamos 5 veces (a los 10 s, 1 min, 5 min, 15 min y 1 h). Solo se llaman direcciones https públicas.


API de socios (plataformas conectadas)

Esta API es para empresas que ya tienen una plataforma en producción (bolsa, inversión, pagos) y quieren enviar sus operaciones reales a PlanetPay para que aparezcan en el mercado (cotizaciones, volúmenes, velas). No es un escaparate de proyectos: solo se publican datos verificables.

Conexión en 4 pasos

  1. Solicitud: en su cuenta PlanetPay, menú Conectar mi plataforma (empresa, sitio HTTPS, cumplimiento, URL de su API de auditoría).
  2. Prueba del dominio: registro DNS TXT planetpay-verify=<token> (en @ o _planetpay), leído mediante Google DNS-over-HTTPS, o un archivo https://<dominio>/.well-known/planetpay-verify.txt con el token. Se comprueba cada hora y luego cada día; prueba ausente 3 veces seguidas = conexión suspendida.
  3. Revisión y fase de pruebas: el equipo de PlanetPay revisa el cumplimiento y abre la fase de pruebas (claves pk_test_…). La aprobación exige un dominio verificado y una API de auditoría que haya respondido al menos una vez.
  4. Producción: claves pk_live_…, operaciones reales, doble verificación de cada operación.

El secreto de una clave (psk_…) se muestra una sola vez y permanece cifrado en nuestro lado. Dos claves activas como máximo por modo (rotación: cree la nueva, cambie, revoque la antigua).

Autenticación de socios

Base: https://planetpay.site/v1/partner (también en /api/v1/partner). Solo HTTPS, JSON UTF-8, 64 KB máx.

Cabecera Valor
X-Partner-Key (or X-Partner-Id) pk_test_… / pk_live_…
X-Timestamp (or X-Partner-Timestamp) hora Unix en segundos (±5 min)
X-Signature (or X-Partner-Signature) hex(HMAC-SHA256(secret, timestamp + "." + METHOD + "." + path + "." + raw_body))

path = la ruta llamada, sin dominio ni parámetros (/v1/partner/events). Una firma se acepta una sola vez (protección contra repeticiones): firme de nuevo cada solicitud, reintentos incluidos. Límite: 300 solicitudes/min por clave.

Endpoints

Método Ruta Uso
GET /v1/partner/health estado del servicio, sin autenticación
GET /v1/partner/status comprobación de conexión: socio, estado, modo, dominio, modo real permitido
POST /v1/partner/events enviar un evento → 202 aceptado, 200 duplicado, 409 conflicto, 422 inválido
GET /v1/partner/events/{event_id} estado de un evento enviado
GET /v1/partner/events?status=&type=&limit=&before= lista (más recientes primero, cursor next_before)
GET /v1/partner/assets sus activos conocidos por PlanetPay: estado (listado o no), último precio verificado, variación y volumen 24 h
GET /v1/partner/transactions/{trade_id} una operación vista por PlanetPay: conservada o anulada, doble verificación, presencia en las cotizaciones, comisiones
GET /v1/partner/commissions?status=&limit=&before= líneas de comisión (más recientes primero, cursor next_before)
GET /v1/partner/reports?from=AAAA-MM-DD&to=AAAA-MM-DD informe del periodo (30 días por defecto, 366 máx.): eventos, operaciones, volumen por activo, comisiones, extractos, regla de tarifas

Formato de un evento

{
  "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.mi-plataforma.com/trades/TR-88412"
  }
}
type obligatorio en 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 para anular una operación)
asset.price_updated asset_id, price, currency (precio indicativo, no entra en las velas)

Reglas: event_id único en su lado (reenviado idéntico = duplicado, otro contenido = 409); occurred_at ISO 8601 con zona horaria, no futuro, 30 días máx.; decimales positivos como cadenas (8 decimales máx.); trade_id identifica una ejecución (nunca se cuenta dos veces). Una orden nunca se trata como operación ejecutada. Cada activo pertenece a su plataforma: nunca se mezcla con las cotizaciones de otros socios.

Doble verificación (antifraude)

Una operación recibida no se publica de inmediato. PlanetPay llama a su API de auditoría y solo conserva la operación si existe en su sistema con el mismo activo, cantidad, precio y divisa, con el estado settled.

GET https://api.mi-plataforma.com/planetpay/audit/transaction/{id}
X-PlanetPay-Partner:   ptn_…
X-PlanetPay-Timestamp: 1761643812
X-PlanetPay-Signature: hex(HMAC-SHA256(secreto_de_la_clave, timestamp + ".GET." + ruta_y_parametros + "."))

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

Webhooks de socios (notificaciones de PlanetPay)

Configure una URL HTTPS en su dominio (pestaña Webhooks): PlanetPay le avisa en cuanto ocurre algo, sin necesidad de consultar la API.

Evento Cuándo
event.accepted / event.rejected un evento enviado es aceptado / rechazado (con el error)
trade.verified operación confirmada por la doble verificación, publicada
trade.mismatch / trade.failed operación rechazada / nunca confirmada
partner.status_changed cambió el estado de su plataforma
asset.status_changed uno de sus activos listado o suspendido
key.revoked una clave API revocada
webhook.test prueba enviada desde la consola
POST https://api.mi-plataforma.com/planetpay/webhooks
X-PlanetPay-Event:     trade.verified
X-PlanetPay-Delivery:  whd_…        (idéntico en cada reintento: deduplique con él)
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}}

Responda un 2xx en menos de 10 s (procese después en una cola). Si no, se reintenta tras 1, 5, 30 min, 2, 6, 12 y 24 h, luego falla (reenvío desde la consola). El secreto whsec_… es distinto de sus claves API y se muestra una sola vez.

Comisiones de PlanetPay

PlanetPay cobra una comisión por cada operación que usted declara, según su contrato: porcentaje de compra, porcentaje de venta y/o importe fijo por operación (en la moneda de la operación). La regla queda fijada en la línea cuando llega la operación: un cambio de tarifa nunca reescribe el historial y un evento recibido varias veces crea una sola línea.

Estado Significado
pending operación aún no confirmada por la doble verificación, o dentro del plazo de anulación (3 días por defecto)
earned devengada
invoiced incluida en un extracto mensual
paid extracto pagado
cancelled operación anulada (transaction.reversed) o no confirmada (mismatch, failed)

Las comisiones reales devengadas de un mes terminado se agrupan en un extracto mensual por moneda (PS…). Una operación anulada después de facturar genera una línea adjustment negativa en el extracto siguiente. Las comisiones del modo de prueba son solo una simulación y nunca se facturan. Detalle y exportación CSV: pestaña «Comisiones» de su panel.

Errores de socios

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 (guardado como rechazado; reenvíelo corregido con el mismo event_id)
429 rate_limited
503 service_unavailable

Ejemplo completo (PHP)

// 1. enviar una operación ejecutada
$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. su API de auditoría (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. recibir nuestros 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()); // deduplique con el id
    return response()->noContent();
});

Errores

Todos los errores tienen la misma forma:

{ "error": { "code": "insufficient_balance", "message": "Insufficient balance.", "required": 10.8 } }
HTTP code Causa
401 unauthorized faltan cabeceras, clave desconocida o firma inválida
401 timestamp_expired X-Timestamp desfasado más de 5 minutos
403 kyc_required clave de producción en una cuenta no verificada
403 account_disabled cuenta suspendida
402 insufficient_balance saldo insuficiente (transferencia + comisiones, servicio, número, tarjeta)
404 not_found operación desconocida
409 wallet_missing sin billetera en USD
422 validation_error campo inválido (fields detalla cada uno)
422 currency_not_supported moneda no disponible
422 operator_not_supported red o país no disponible para una transferencia
422 invalid_phone número inválido
429 rate_limited más de 120 solicitudes por minuto
422 order_refused pedido de servicio rechazado por el proveedor (no se cobra nada)
422 card_refused operación de tarjeta rechazada (plan, límite de tarjetas, importe…); el mensaje indica el motivo
502 card_failed el emisor no pudo crear la tarjeta
404 not_found servicio, número o tarjeta desconocido
409 number_unavailable ningún número disponible para ese servicio y país (no se cobra nada)
409 already_completed el número ya recibió su SMS: no se puede cancelar
503 service_unavailable números virtuales no disponibles temporalmente

Ejemplos completos

Los ejemplos crean una recarga de 20 USD pagada en XOF. Sustituya PK y SK por sus claves.

cURL (bash)

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

// Webhook (Express): verificar la firma sobre el cuerpo BRUTO
// 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') { /* entregar el pedido event.data.reference */ }
//   res.sendStatus(200);
// });

PHP

<?php
const PK = 'su_clave_api';
const SK = 'su_clave_secreta';

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

Python 3

import hashlib, hmac, json, time, requests

PK, SK = "su_clave_api", "su_clave_secreta"

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

Servicios digitales, números virtuales y tarjetas Visa: PHP, Python, Node.js, Ruby, cURL

Cada ejemplo lista los alquileres de herramientas y pide uno, crea una tarjeta Visa y la recarga con 25 USD, y luego compra un número virtual de WhatsApp y espera el código SMS. Ejecútelos primero con una clave de prueba: no se cobra nada. Las claves se leen de las variables de entorno PLANETPAY_KEY y PLANETPAY_SECRET.

La ruta firmada nunca incluye los parámetros de la URL: para GET /v1/services?category=rental, se firma /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"

Preguntas técnicas: contacte con soporte desde su cuenta PlanetPay indicando el identificador de la operación (rch_…, trf_…, ord_…, num_…, crd_……).