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.
docs.fayma.co/llms.txt— guide markdown auto-suffisant, pensé pour les IA.docs.fayma.co/openapi.json— spec OpenAPI 3.1 (import Postman / génération de client).- Clients prêts à l'emploi :
faymaco.js(Node) ·faymaco.py(Python).
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 testfk_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.
Authorization: Bearer fk_live_xxxxxxxxxxxxxxxxxxxx
Environnements
Deux environnements, mêmes endpoints. Utilisez une clé fk_test_ sur le test et fk_live_ en production.
| Environnement | Base URL |
|---|---|
| Production | https://apifayko.peelo.chat/api/v1 |
| Test | https://playground.fayma.co/api/v1 |
https://apifayko.peelo.chat/api/v1
Démarrage rapide
Créez votre premier abonnement en une requête.
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.
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" } }'
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
Crée un abonnement récurrent pour un client.
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é | Type | Description |
|---|---|---|
| customer.name | string requis | Nom du client. |
| customer.phone | string requis | Numéro WhatsApp au format international (+221…). |
| amount | number requis | Montant par cycle. |
| frequency | string requis | monthly · quarterly · semi_annual · annual |
| currency | string | Défaut XOF. |
| startDate | ISO date | Date du 1er cycle. Défaut : maintenant. |
| startNextMonth | boolean | true → 1er cycle le 1er du mois suivant. |
| webhooks.onSuccess | url | URL notifiée à chaque paiement réussi. |
| webhooks.onExpired | url | URL notifiée quand l'échéance passe sans paiement. |
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" }'
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" }) });
{
"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
Paramètres de requête
| Clé | Type | Description |
|---|---|---|
| status | string | Filtre : active, paused, cancelled… |
| limit | number | 1–100. Défaut 20. |
| cursor | string | Curseur de pagination (voir nextCursor). |
La réponse contient data.pagination.nextCursor (ou null). Passez-le en cursor pour la page suivante.
curl ".../v1/subscriptions?status=active&limit=20" \ -H "Authorization: Bearer fk_live_..."
await fetch(base + "/subscriptions?status=active", { headers: { Authorization: `Bearer ${key}` } });
Récupérer un abonnement
Renvoie le détail d'un abonnement par son identifiant.
curl .../v1/subscriptions/6a33... \ -H "Authorization: Bearer fk_live_..."
await fetch(base + `/subscriptions/${id}`, { headers: { Authorization: `Bearer ${key}` } });
Pause · Reprise · Annulation
Pilotez le cycle de vie d'un abonnement. Une annulation est définitive et arrête les relances en cours.
curl -X POST .../v1/subscriptions/6a33.../pause \ -H "Authorization: Bearer fk_live_..."
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éation | 1er message |
|---|---|
aucun startDate | Envoyé automatiquement, dans la minute. |
startDate future | Envoyé à cette date. |
startNextMonth: true | Envoyé le 1er du mois suivant. |
Déroulé d'un cycle, entièrement géré par Faymaco :
- À l'échéance, envoi d'une demande de paiement WhatsApp (+ facture PDF).
- Si impayé : relances automatiques (par défaut J+1, J+3, J+7).
- Au paiement, déclenchement du webhook
subscription.payment.succeeded. - L'échéance avance d'une période et le cycle suivant repart.
{
"customer": { ... },
"amount": 5000,
"frequency": "monthly",
"startNextMonth": true
}
Créer une demande de paiement ponctuelle
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.
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é | Type | Description |
|---|---|---|
| customer.name | string requis | Nom du client. |
| customer.phone | string requis | Numéro WhatsApp au format international (+221…). |
| amount | number requis | Montant demandé (> 0). |
| currency | string | Seul XOF est supporté (défaut). |
| dueDate | ISO date | Date d'envoi de la demande. Défaut : maintenant → envoi dans la minute. |
| reminders | ISO 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). |
| expiresIn | integer | Duré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. |
| expiresAt | ISO date | Date d'expiration absolue, alternative à expiresIn (même fenêtre 60 s – 90 jours). Envoyer les deux → 400 VALIDATION_ERROR. |
| replaceExisting | boolean | true → annule la demande en cours de ce numéro au lieu de renvoyer 409. Les ids annulés reviennent dans data.replaced. |
| webhooks.onSuccess | url | URL notifiée au paiement (payment_request.succeeded). |
| webhooks.onExpired | url | URL notifiée à l'expiration (payment_request.expired). Nécessite expiresIn/expiresAt ; à défaut, l'URL onSuccess est utilisée. |
| externalRef | string | Votre identifiant interne, renvoyé tel quel dans le webhook. |
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.
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" }'
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" }) });
{
"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)
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églage | Effet |
|---|---|
| ne pas envoyer reminders | Un seul message WhatsApp, jamais renvoyé. C'est le comportement par défaut. |
| ne pas envoyer dueDate | Envoi immédiat (dans la minute). |
| expiresIn: 1800 | 30 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: true | Si le client recommence, l'ancien lien est annulé (ids dans data.replaced) au lieu d'un 409. |
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é.
POST /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.
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" }'
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 }) });
{
"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": []
}
}
{
"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
Paramètres de requête
| Clé | Type | Description |
|---|---|---|
| status | string | Filtre : active, paid, overdue, cancelled, expired. status=expired liste les checkouts abandonnés. |
| source | string | api pour ne voir que les demandes créées via l'API (par défaut, celles du dashboard sont incluses). |
| limit | number | 1–100. Défaut 20. |
| cursor | string | Curseur de pagination (voir nextCursor). |
Les abonnements n'apparaissent pas ici — uniquement les demandes ponctuelles.
curl ".../v1/payment-requests?status=active&source=api" \ -H "Authorization: Bearer fk_live_..."
await fetch(base + "/payment-requests?source=api", { headers: { Authorization: `Bearer ${key}` } });
Détail · Annulation
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
| Statut | Sens |
|---|---|
| active | Demande créée, envoi/relances en cours. |
| paid | Payée (Wave / Orange Money, ou marquée payée par le marchand). |
| overdue | Échéance dépassée sans paiement. |
| cancelled | Annulée volontairement (par vous, ou par replaceExisting) — plus aucun message ne partira. |
| expired | Validité du checkout écoulée sans paiement (panier abandonné). Sort des listes, y compris de celle du client sur WhatsApp. |
403 QUOTA_EXCEEDED. Une demande expirée ne restitue pas son slot.curl -X POST .../v1/payment-requests/6a7b.../cancel \ -H "Authorization: Bearer fk_live_..."
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énement | Quand |
|---|---|
| subscription.payment.succeeded | Un cycle d'abonnement a été payé. |
| subscription.payment.failed | L'échéance est passée sans paiement (cycle impayé après ~10 jours) → envoyé sur onExpired. |
| payment_request.succeeded | Une demande de paiement ponctuelle a été payée. data : paymentRequestId, externalRef, paidAt, source (platform · manual), customer, amount, amountPaid, currency. |
| payment_request.expired | Un 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ête | Valeur |
|---|---|
| X-Faymaco-Event | nom de l'événement |
| X-Faymaco-Timestamp | unix (secondes) |
| X-Faymaco-Signature | t=<ts>,v1=<hmac> |
{
"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.
2xx rapidement et traitez en asynchrone.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" } }.
| HTTP | code | Sens |
|---|---|---|
| 401 | NO_API_KEY / INVALID_API_KEY | Clé absente, invalide ou révoquée. |
| 403 | FEATURE_NOT_AVAILABLE | Plan sans accès API (Pro+ requis). |
| 403 | QUOTA_EXCEEDED | Quota mensuel de demandes atteint — création refusée (details: { used, limit, plan }). |
| 403 | ACCOUNT_SUSPENDED | Compte suspendu. |
| 400 | VALIDATION_ERROR | Corps de requête invalide. |
| 400 | INVALID_STATE | Transition de statut impossible (ex. annuler une demande déjà payée). |
| 400 | INVALID_DATE | dueDate passée, date de relance non postérieure à dueDate ou postérieure à l'expiration, ou fenêtre de validité hors 60 s – 90 jours. |
| 400 | UNSUPPORTED_CURRENCY | Seul XOF est supporté. |
| 403 | REMINDER_LIMIT_EXCEEDED | Trop de dates de relance pour votre plan. |
| 404 | SUBSCRIPTION_NOT_FOUND | Abonnement introuvable. |
| 404 | PAYMENT_REQUEST_NOT_FOUND | Demande de paiement introuvable. |
| 409 | DUPLICATE_REQUEST | Demande 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). |
| 409 | IDEMPOTENCY_IN_PROGRESS | Requête identique en cours. |
| 429 | RATE_LIMITED | Trop de requêtes — réessayez après Retry-After. |
{
"success": false,
"error": {
"code": "FEATURE_NOT_AVAILABLE",
"message": "Plan Pro requis."
}
}