Documentation API Faymaco

Encaissez vos clients via Faymaco : collecte de paiement par WhatsApp (Wave / Orange Money), relances automatiques, et notifications en temps réel par webhook signé.

Deux objets, un même moteur : l'abonnement (facturation récurrente, cycle après cycle) et la demande de paiement ponctuelle (un encaissement unique — facture, commande, acompte). Dans les deux cas, Faymaco envoie la demande de paiement, relance en cas d'impayé, encaisse, et vous notifie à chaque paiement. Vous n'avez ni à planifier les envois ni à gérer l'encaissement — vous réagissez simplement au webhook. Accès disponible à partir du plan Pro.

Intégration assistée par IA. Donnez l'un de ces liens à votre assistant (ChatGPT, Claude, Cursor…) : il code l'intégration tout seul.

Authentification

Chaque requête s'authentifie avec une clé API secrète envoyée dans l'en-tête Authorization. Créez et gérez vos clés depuis votre dashboard Faymaco, page Développeurs.

  • Clés de production fk_live_… et de test fk_test_….
  • La clé n'est affichée qu'une seule fois, à la création — stockez-la en lieu sûr.
  • Une clé révoquée renvoie 401. Ne l'exposez jamais côté client.
En-tête d'authentification
Authorization: Bearer fk_live_xxxxxxxxxxxxxxxxxxxx

Environnements

Deux environnements, mêmes endpoints. Utilisez une clé fk_test_ sur le test et fk_live_ en production.

EnvironnementBase URL
Productionhttps://apifayko.peelo.chat/api/v1
Testhttps://playground.fayma.co/api/v1
Base URL
https://apifayko.peelo.chat/api/v1

Démarrage rapide

Créez votre premier abonnement en une requête.

Idempotency-Key — envoyez-le systématiquement. C'est aujourd'hui votre meilleure protection contre les abonnements en double : deux POST identiques (double-clic, réessai après timeout, bug réseau) sans cet en-tête créent deux abonnements actifs pour le même client — donc deux demandes de paiement WhatsApp par mois. C'est vous (le client) qui générez la valeur, unique par opération (votre identifiant de commande, ou crypto.randomUUID()). En renvoyant la même clé, Faymaco rejoue la réponse initiale au lieu de créer un 2e abonnement. Validité : 24 h.
Requête — cURL
curl -X POST https://apifayko.peelo.chat/api/v1/subscriptions \
  -H "Authorization: Bearer fk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cmd-12345" \
  -d '{
    "customer": { "name": "Awa Diop", "phone": "+221770000000" },
    "amount": 5000, "currency": "XOF", "frequency": "monthly",
    "webhooks": {
      "onSuccess": "https://votre-app.com/paiement-ok",
      "onExpired": "https://votre-app.com/impaye"
    }
  }'
Requête — JavaScript
const res = await fetch("https://apifayko.peelo.chat/api/v1/subscriptions", {
  method: "POST",
  headers: {
    "Authorization": "Bearer fk_live_...",
    "Content-Type": "application/json",
    "Idempotency-Key": "cmd-12345"
  },
  body: JSON.stringify({
    customer: { name: "Awa Diop", phone: "+221770000000" },
    amount: 5000, currency: "XOF", frequency: "monthly",
    webhooks: { onSuccess: "https://votre-app.com/paiement-ok",
                onExpired: "https://votre-app.com/impaye" }
  })
});
const data = await res.json();

Créer un abonnement

POST /v1/subscriptions
Idempotent

Crée un abonnement récurrent pour un client.

Toujours joindre un en-tête Idempotency-Key unique. Sans lui, un appel rejoué ou envoyé deux fois crée deux abonnements actifs pour le même client (double facturation). Avec la même clé, la 2e requête renvoie l'abonnement déjà créé.

Paramètres du corps

CléTypeDescription
customer.namestring requisNom du client.
customer.phonestring requisNuméro WhatsApp au format international (+221…).
amountnumber requisMontant par cycle.
frequencystring requismonthly · quarterly · semi_annual · annual
currencystringDéfaut XOF.
startDateISO dateDate du 1er cycle. Défaut : maintenant.
startNextMonthbooleantrue → 1er cycle le 1er du mois suivant.
webhooks.onSuccessurlURL notifiée à chaque paiement réussi.
webhooks.onExpiredurlURL notifiée quand l'échéance passe sans paiement.
cURL
curl -X POST .../v1/subscriptions \
  -H "Authorization: Bearer fk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cmd-12345" \
  -d '{
    "customer": { "name": "Awa Diop",
                  "phone": "+221770000000" },
    "amount": 5000,
    "frequency": "monthly"
  }'
JavaScript
await fetch(base + "/subscriptions", {
  method: "POST",
  headers: { Authorization: `Bearer ${key}`,
             "Content-Type": "application/json",
             "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({
    customer: { name: "Awa Diop",
                phone: "+221770000000" },
    amount: 5000, frequency: "monthly"
  })
});
Réponse · 201
{
  "success": true,
  "data": {
    "subscription": {
      "id": "6a33...",
      "status": "active",
      "customer": { "name": "Awa Diop",
                     "phone": "+221770000000" },
      "pricing": { "amount": 5000,
                    "currency": "XOF",
                    "frequency": "monthly" },
      "nextDueDate": "2026-07-01T00:00:00Z",
      "cycleCount": 0
    }
  }
}

Lister les abonnements

GET /v1/subscriptions

Paramètres de requête

CléTypeDescription
statusstringFiltre : active, paused, cancelled
limitnumber1–100. Défaut 20.
cursorstringCurseur de pagination (voir nextCursor).

La réponse contient data.pagination.nextCursor (ou null). Passez-le en cursor pour la page suivante.

cURL
curl ".../v1/subscriptions?status=active&limit=20" \
  -H "Authorization: Bearer fk_live_..."
JavaScript
await fetch(base + "/subscriptions?status=active", {
  headers: { Authorization: `Bearer ${key}` }
});

Récupérer un abonnement

GET /v1/subscriptions/:id

Renvoie le détail d'un abonnement par son identifiant.

cURL
curl .../v1/subscriptions/6a33... \
  -H "Authorization: Bearer fk_live_..."
JavaScript
await fetch(base + `/subscriptions/${id}`, {
  headers: { Authorization: `Bearer ${key}` }
});

Pause · Reprise · Annulation

POST /v1/subscriptions/:id/pause

POST /v1/subscriptions/:id/resume

POST /v1/subscriptions/:id/cancel

Pilotez le cycle de vie d'un abonnement. Une annulation est définitive et arrête les relances en cours.

cURL
curl -X POST .../v1/subscriptions/6a33.../pause \
  -H "Authorization: Bearer fk_live_..."
JavaScript
await fetch(base + `/subscriptions/${id}/pause`, {
  method: "POST",
  headers: { Authorization: `Bearer ${key}` }
});

Cycle de vie & timing

Quand votre client est-il sollicité ? Cela dépend des champs fournis à la création :

À la création1er message
aucun startDateEnvoyé automatiquement, dans la minute.
startDate futureEnvoyé à cette date.
startNextMonth: trueEnvoyé le 1er du mois suivant.

Déroulé d'un cycle, entièrement géré par Faymaco :

  1. À l'échéance, envoi d'une demande de paiement WhatsApp (+ facture PDF).
  2. Si impayé : relances automatiques (par défaut J+1, J+3, J+7).
  3. Au paiement, déclenchement du webhook subscription.payment.succeeded.
  4. L'échéance avance d'une période et le cycle suivant repart.
Différer le 1er message
{
  "customer": { ... },
  "amount": 5000,
  "frequency": "monthly",
  "startNextMonth": true
}
Vous n'avez rien à planifier ni à encaisser : créez l'abonnement, Faymaco gère l'envoi, les relances et la collecte.

Créer une demande de paiement ponctuelle

POST /v1/payment-requests
Idempotent

Un encaissement unique (facture, commande, acompte…) : Faymaco envoie la demande de paiement WhatsApp au client, relance s'il ne paie pas, encaisse via Wave / Orange Money et vous notifie. Aucun cycle suivant — c'est la différence avec un abonnement.

Toujours joindre un en-tête Idempotency-Key unique (même principe que pour les abonnements) : un appel rejoué avec la même clé renvoie la demande déjà créée au lieu d'en créer une deuxième. En plus, une seule demande active est admise par (numéro, mois civil) — un doublon renvoie 409 DUPLICATE_REQUEST avec l'id existant dans details.

Paramètres du corps

CléTypeDescription
customer.namestring requisNom du client.
customer.phonestring requisNuméro WhatsApp au format international (+221…).
amountnumber requisMontant demandé (> 0).
currencystringSeul XOF est supporté (défaut).
dueDateISO dateDate d'envoi de la demande. Défaut : maintenant → envoi dans la minute.
remindersISO date[]Dates exactes de relance, chacune postérieure à dueDate et antérieure à l'expiration. À omettre pour un paiement unique : aucune relance n'est alors envoyée. Plafond selon votre plan (Pro 3 · Max 5 · Enterprise 10).
expiresInintegerDurée de validité en secondes à partir de dueDate (60 s min, 90 jours max). Non payée à l'échéance, la demande passe en expired, les envois en attente sont annulés et payment_request.expired est émis. Absent → la demande reste ouverte indéfiniment.
expiresAtISO dateDate d'expiration absolue, alternative à expiresIn (même fenêtre 60 s – 90 jours). Envoyer les deux → 400 VALIDATION_ERROR.
replaceExistingbooleantrue → annule la demande en cours de ce numéro au lieu de renvoyer 409. Les ids annulés reviennent dans data.replaced.
webhooks.onSuccessurlURL notifiée au paiement (payment_request.succeeded).
webhooks.onExpiredurlURL notifiée à l'expiration (payment_request.expired). Nécessite expiresIn/expiresAt ; à défaut, l'URL onSuccess est utilisée.
externalRefstringVotre identifiant interne, renvoyé tel quel dans le webhook.
Paiement unique (checkout) — pour un service qu'on paie une seule fois, où relancer n'a pas de sens : omettez reminders (un seul message WhatsApp, jamais renvoyé), passez expiresIn (ex. 1800 = 30 min) et replaceExisting: true. Un panier abandonné se ferme alors tout seul au lieu de rester dans la liste du client et de bloquer sa prochaine demande. Les demandes créées avec une expiration ne déclenchent jamais le contrôle de doublon mensuel : un même client peut acheter plusieurs fois dans le mois.
amount vs amountToPay — si votre compte est en mode « frais à la charge du client » (fees.mode = customer_pays), Faymaco majore le montant réglé par le client pour que vous receviez exactement amount. amountToPay dans la réponse est ce que le client paie réellement.
Requête — cURL
curl -X POST .../v1/payment-requests \
  -H "Authorization: Bearer fk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4821" \
  -d '{
    "customer": { "name": "Awa Diop",
                  "phone": "+221770000000" },
    "amount": 25000,
    "dueDate": "2026-08-20T09:00:00Z",
    "reminders": ["2026-08-22T09:00:00Z",
                  "2026-08-25T09:00:00Z"],
    "webhooks": { "onSuccess": "https://acme.com/hooks/faymaco" },
    "externalRef": "order-4821"
  }'
Requête — JavaScript
await fetch(base + "/payment-requests", {
  method: "POST",
  headers: { Authorization: `Bearer ${key}`,
             "Content-Type": "application/json",
             "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({
    customer: { name: "Awa Diop",
                phone: "+221770000000" },
    amount: 25000,
    dueDate: "2026-08-20T09:00:00Z",
    reminders: ["2026-08-22T09:00:00Z"],
    webhooks: { onSuccess: "https://acme.com/hooks/faymaco" },
    externalRef: "order-4821"
  })
});
Réponse · 201
{
  "success": true,
  "data": {
    "paymentRequest": {
      "id": "6a7b...",
      "status": "active",
      "customer": { "name": "Awa Diop",
                     "phone": "+221770000000" },
      "amount": 25000,
      "currency": "XOF",
      "amountToPay": 25000,
      "dueDate": "2026-08-20T09:00:00.000Z",
      "paidAt": null,
      "reminders": ["2026-08-22T09:00:00.000Z",
                    "2026-08-25T09:00:00.000Z"],
      "source": "api",
      "externalRef": "order-4821"
    },
    "scheduled": [
      { "type": "send_payment_request",
        "scheduledAt": "2026-08-20T09:00:00.000Z" },
      { "type": "send_reminder",
        "scheduledAt": "2026-08-22T09:00:00.000Z" },
      { "type": "send_reminder",
        "scheduledAt": "2026-08-25T09:00:00.000Z" }
    ]
  }
}

Paiement unique (checkout)

POST /v1/payment-requests
Sans relance

Le cas d'un service qu'on paie une seule fois : le client reçoit un lien, il paie ou il abandonne. Relancer n'a pas de sens, et une demande abandonnée ne doit pas s'accumuler dans sa liste ni bloquer son prochain achat. Trois réglages suffisent, sur le même endpoint.

RéglageEffet
ne pas envoyer remindersUn seul message WhatsApp, jamais renvoyé. C'est le comportement par défaut.
ne pas envoyer dueDateEnvoi immédiat (dans la minute).
expiresIn: 180030 minutes pour payer. Passé ce délai sans paiement : statut expired, envois en attente annulés, webhook payment_request.expired, et la demande disparaît de la liste WhatsApp du client.
replaceExisting: trueSi le client recommence, l'ancien lien est annulé (ids dans data.replaced) au lieu d'un 409.
Timing — l'expiration est traitée par le même cron d'une minute que les envois : expired arrive donc dans les ~60 s qui suivent expiresAt. Un paiement qui arrive avant que le job ne tourne gagne toujours : la demande est déjà paid et l'expiration la saute. Rien à réconcilier de votre côté.
Annulation immédiatePOST /v1/payment-requests/:id/cancel reste disponible quand c'est votre application qui décide (client parti, commande annulée). expiresIn couvre le cas où personne ne décide rien. Les deux mènent au même résultat : la demande sort des listes et les envois programmés sont annulés.

Statut final

Une demande atteint paid ou expired, jamais les deux — vous ne recevrez donc jamais deux webhooks contradictoires pour un même externalRef. Pour retrouver les paniers abandonnés : GET /v1/payment-requests?status=expired.

Requête — cURL
curl -X POST .../v1/payment-requests \
  -H "Authorization: Bearer fk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4821" \
  -d '{
    "customer": { "name": "Awa Diop",
                  "phone": "+221770000000" },
    "amount": 25000,
    "expiresIn": 1800,
    "replaceExisting": true,
    "webhooks": {
      "onSuccess": "https://acme.com/hooks/faymaco",
      "onExpired": "https://acme.com/hooks/faymaco" },
    "externalRef": "order-4821"
  }'
Requête — JavaScript
await fetch(base + "/payment-requests", {
  method: "POST",
  headers: { Authorization: `Bearer ${key}`,
             "Content-Type": "application/json",
             "Idempotency-Key": orderId },
  body: JSON.stringify({
    customer: { name: "Awa Diop",
                phone: "+221770000000" },
    amount: 25000,
    // pas de reminders → aucune relance
    expiresIn: 1800,
    replaceExisting: true,
    webhooks: { onSuccess: "https://acme.com/hooks/faymaco",
                onExpired: "https://acme.com/hooks/faymaco" },
    externalRef: orderId
  })
});
Réponse · 201
{
  "success": true,
  "data": {
    "paymentRequest": {
      "id": "6a7b...",
      "status": "active",
      "amount": 25000,
      "amountToPay": 25000,
      "dueDate": "2026-08-20T09:00:00.000Z",
      "expiresAt": "2026-08-20T09:30:00.000Z",
      "expiredAt": null,
      "reminders": [],
      "externalRef": "order-4821"
    },
    "scheduled": [
      { "type": "send_payment_request",
        "scheduledAt": "2026-08-20T09:00:00.000Z" },
      { "type": "expire_payment_request",
        "scheduledAt": "2026-08-20T09:30:00.000Z" }
    ],
    "replaced": []
  }
}
Webhook — payment_request.expired
{
  "id": "evt_xxx",
  "event": "payment_request.expired",
  "created": "2026-08-20T09:30:12Z",
  "data": {
    "paymentRequestId": "6a7b...",
    "externalRef": "order-4821",
    "expiresAt": "2026-08-20T09:30:00.000Z",
    "expiredAt": "2026-08-20T09:30:12.000Z",
    "customer": { "name": "Awa Diop",
                   "phone": "+221770000000" },
    "amount": 25000,
    "currency": "XOF"
  }
}

Lister les demandes

GET /v1/payment-requests

Paramètres de requête

CléTypeDescription
statusstringFiltre : active, paid, overdue, cancelled, expired. status=expired liste les checkouts abandonnés.
sourcestringapi pour ne voir que les demandes créées via l'API (par défaut, celles du dashboard sont incluses).
limitnumber1–100. Défaut 20.
cursorstringCurseur de pagination (voir nextCursor).

Les abonnements n'apparaissent pas ici — uniquement les demandes ponctuelles.

cURL
curl ".../v1/payment-requests?status=active&source=api" \
  -H "Authorization: Bearer fk_live_..."
JavaScript
await fetch(base + "/payment-requests?source=api", {
  headers: { Authorization: `Bearer ${key}` }
});

Détail · Annulation

GET /v1/payment-requests/:id

POST /v1/payment-requests/:id/cancel

L'annulation passe la demande en cancelled et stoppe l'envoi et les relances en attente. Une demande déjà payée, annulée ou expirée ne peut pas être annulée (400 INVALID_STATE).

Statuts

StatutSens
activeDemande créée, envoi/relances en cours.
paidPayée (Wave / Orange Money, ou marquée payée par le marchand).
overdueÉchéance dépassée sans paiement.
cancelledAnnulée volontairement (par vous, ou par replaceExisting) — plus aucun message ne partira.
expiredValidité du checkout écoulée sans paiement (panier abandonné). Sort des listes, y compris de celle du client sur WhatsApp.
Chaque demande consomme un slot du quota mensuel de votre plan, comme une demande créée depuis le dashboard. Quota atteint → 403 QUOTA_EXCEEDED. Une demande expirée ne restitue pas son slot.
cURL
curl -X POST .../v1/payment-requests/6a7b.../cancel \
  -H "Authorization: Bearer fk_live_..."
JavaScript
await fetch(base + `/payment-requests/${id}/cancel`, {
  method: "POST",
  headers: { Authorization: `Bearer ${key}` }
});

Webhooks — événements

Faymaco appelle votre URL webhooks.onSuccess en POST à chaque événement.

ÉvénementQuand
subscription.payment.succeededUn cycle d'abonnement a été payé.
subscription.payment.failedL'échéance est passée sans paiement (cycle impayé après ~10 jours) → envoyé sur onExpired.
payment_request.succeededUne demande de paiement ponctuelle a été payée. data : paymentRequestId, externalRef, paidAt, source (platform · manual), customer, amount, amountPaid, currency.
payment_request.expiredUn checkout (expiresIn/expiresAt) a expiré sans paiement → envoyé sur onExpired, ou sur onSuccess si onExpired n'est pas défini. data : paymentRequestId, externalRef, expiresAt, expiredAt, customer, amount, currency. Exclusif avec succeeded pour une même demande.

En-têtes envoyés

En-têteValeur
X-Faymaco-Eventnom de l'événement
X-Faymaco-Timestampunix (secondes)
X-Faymaco-Signaturet=<ts>,v1=<hmac>
Corps reçu
{
  "id": "evt_xxx",
  "event": "subscription.payment.succeeded",
  "created": "2026-07-01T09:00:00Z",
  "data": {
    "subscriptionId": "6a33...",
    "cycleNumber": 1,
    "paidAt": "2026-07-01T09:00:00Z",
    "customer": { "name": "Awa Diop",
                   "phone": "+221770000000" },
    "pricing": { "amount": 5000,
                  "currency": "XOF" }
  }
}

Vérifier la signature

Chaque webhook est signé : vérifiez la signature pour garantir qu'il vient bien de Faymaco et que le corps n'a pas été modifié. La signature v1 = HMAC_SHA256(secret, "<timestamp>.<corps brut>").

Le secret est celui affiché sur votre page Développeurs (bouton « afficher ») — le même que GET /api/fayko/api-keys/webhook-secret. Il est partagé (jamais transmis dans la requête) et peut être régénéré en cas de fuite.

Retries — en cas de réponse non-2xx ou de timeout (10s), Faymaco réessaie jusqu'à 3 fois (backoff ~2s puis ~10s). Répondez 2xx rapidement et traitez en asynchrone.
Vérification — Node.js
const crypto = require("crypto");

function verify(req, secret) {
  const m = /t=(\d+),v1=([0-9a-f]+)/
            .exec(req.headers["x-faymaco-signature"]);
  if (!m) return false;
  const [, ts, v1] = m;
  // anti-rejeu : refuser si trop ancien (> 5 min)
  if (Math.abs(Date.now()/1000 - Number(ts)) > 300)
    return false;
  const exp = crypto.createHmac("sha256", secret)
    .update(`${ts}.${req.rawBody}`).digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(exp), Buffer.from(v1));
}

Codes d'erreur

Toutes les erreurs suivent la forme { "success": false, "error": { "code", "message" } }.

HTTPcodeSens
401NO_API_KEY / INVALID_API_KEYClé absente, invalide ou révoquée.
403FEATURE_NOT_AVAILABLEPlan sans accès API (Pro+ requis).
403QUOTA_EXCEEDEDQuota mensuel de demandes atteint — création refusée (details: { used, limit, plan }).
403ACCOUNT_SUSPENDEDCompte suspendu.
400VALIDATION_ERRORCorps de requête invalide.
400INVALID_STATETransition de statut impossible (ex. annuler une demande déjà payée).
400INVALID_DATEdueDate passée, date de relance non postérieure à dueDate ou postérieure à l'expiration, ou fenêtre de validité hors 60 s – 90 jours.
400UNSUPPORTED_CURRENCYSeul XOF est supporté.
403REMINDER_LIMIT_EXCEEDEDTrop de dates de relance pour votre plan.
404SUBSCRIPTION_NOT_FOUNDAbonnement introuvable.
404PAYMENT_REQUEST_NOT_FOUNDDemande de paiement introuvable.
409DUPLICATE_REQUESTDemande active déjà existante pour ce numéro ce mois-ci (details.existingPaymentRequestId). À résoudre avec replaceExisting: true, POST /:id/cancel, ou expiresIn (les checkouts en sont exemptés).
409IDEMPOTENCY_IN_PROGRESSRequête identique en cours.
429RATE_LIMITEDTrop de requêtes — réessayez après Retry-After.
Exemple d'erreur · 403
{
  "success": false,
  "error": {
    "code": "FEATURE_NOT_AVAILABLE",
    "message": "Plan Pro requis."
  }
}