# Faymaco API v1 — LLM integration guide > Faymaco lets a SaaS collect payments from its customers in West Africa via > WhatsApp (Wave / Orange Money). Two objects, one engine: a *subscription* (recurring > billing, cycle after cycle) and a *payment request* (a one-off collection — invoice, > order, deposit). In both cases Faymaco sends the payment request over WhatsApp, sends > automatic reminders if unpaid, collects the money, and notifies you with a **signed > webhook** at every payment. You never schedule sends or handle collection yourself — > you just react to webhooks. > > This file is self-contained: an AI agent can implement a full integration from it alone. > Machine-readable spec: https://docs.fayma.co/openapi.json — Human docs: https://docs.fayma.co > Ready-to-use clients: https://docs.fayma.co/faymaco.js (Node) · https://docs.fayma.co/faymaco.py (Python) ## Base URLs | Environment | Base URL | API key prefix | |-------------|--------------------------------------------|----------------| | Production | `https://apifayko.peelo.chat/api/v1` | `fk_live_` | | Test | `https://playground.fayma.co/api/v1` | `fk_test_` | Same endpoints in both. Pick the key matching the environment. ## Authentication Every request sends a secret API key in the `Authorization` header: ``` Authorization: Bearer fk_live_xxxxxxxxxxxxxxxxxxxx ``` - Create/manage keys in the Faymaco dashboard → **Developers** page (`https://app.fayma.co` → API). - The key is shown **once** at creation. Store it server-side; never expose it client-side. - A revoked key returns `401`. - API access requires a **Pro plan or higher**. Otherwise `403 FEATURE_NOT_AVAILABLE`. ## Conventions - All responses are JSON: success → `{ "success": true, "data": { ... } }`; error → `{ "success": false, "error": { "code": "...", "message": "..." } }`. - Phone numbers are international format, e.g. `+221770000000`. - Currency default is `XOF`. Amounts are integers in the smallest practical unit (XOF has no decimals). - `Idempotency-Key` (request header — **always send it on POST /subscriptions**): a unique value you generate per operation (e.g. your order id, or `crypto.randomUUID()`). If you retry with the **same** key after a timeout, Faymaco replays the original response instead of creating a duplicate. TTL 24h. ⚠️ **This is currently the ONLY safeguard against duplicate subscriptions.** There is no automatic server-side dedupe by phone yet: two `POST /subscriptions` for the same customer **without** an Idempotency-Key (double-submit, retry, network glitch) create **two active subscriptions** → the customer is billed **twice per month** over WhatsApp. Generate the key once per logical operation and reuse it across retries — never generate a fresh key on retry. ## Endpoints ### Create a subscription `POST /subscriptions` — idempotent. **Always send an `Idempotency-Key` header** (see Conventions): it is the only protection against accidentally creating a duplicate subscription for the same customer. Body parameters: | Key | Type | Required | Description | |----------------------|----------|----------|-------------| | `customer.name` | string | yes | Customer name. | | `customer.phone` | string | yes | WhatsApp number, international (`+221...`). | | `amount` | number | yes | Amount charged per cycle. | | `frequency` | string | yes | One of `monthly`, `quarterly`, `semi_annual`, `annual`. | | `currency` | string | no | Default `XOF`. | | `startDate` | ISO date | no | Date of the 1st cycle. Default: now. | | `startNextMonth` | boolean | no | `true` → 1st cycle on the 1st of next month. | | `webhooks.onSuccess` | url | no | Called on every successful payment. | | `webhooks.onExpired` | url | no | Called when a due date passes unpaid. | Example request: ```bash curl -X POST https://apifayko.peelo.chat/api/v1/subscriptions \ -H "Authorization: Bearer fk_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-12345" \ -d '{ "customer": { "name": "Awa Diop", "phone": "+221770000000" }, "amount": 5000, "currency": "XOF", "frequency": "monthly", "webhooks": { "onSuccess": "https://your-app.com/webhooks/faymaco", "onExpired": "https://your-app.com/webhooks/faymaco" } }' ``` Response `201`: ```json { "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 } } } ``` ### List subscriptions `GET /subscriptions` Query parameters: `status` (`active` | `paused` | `cancelled` ...), `limit` (1–100, default 20), `cursor` (pagination). The response contains `data.pagination.nextCursor` (or `null`); pass it back as `cursor` for the next page. ```bash curl "https://apifayko.peelo.chat/api/v1/subscriptions?status=active&limit=20" \ -H "Authorization: Bearer fk_live_..." ``` ### Retrieve a subscription `GET /subscriptions/:id` — returns one subscription by id. ### Pause / Resume / Cancel - `POST /subscriptions/:id/pause` - `POST /subscriptions/:id/resume` - `POST /subscriptions/:id/cancel` (definitive; stops ongoing reminders) ```bash curl -X POST https://apifayko.peelo.chat/api/v1/subscriptions/6a33.../pause \ -H "Authorization: Bearer fk_live_..." ``` ### Create a one-off payment request `POST /payment-requests` — idempotent. **Always send an `Idempotency-Key` header** (same rule as subscriptions). A one-off collection: Faymaco sends the WhatsApp payment request, reminds on the exact dates you provide, collects, and fires the `payment_request.succeeded` webhook. No next cycle — that is the difference with a subscription. Body parameters: | Key | Type | Required | Description | |----------------------|------------|----------|-------------| | `customer.name` | string | yes | Customer name. | | `customer.phone` | string | yes | WhatsApp number, international (`+221...`). | | `amount` | number | yes | Amount requested (> 0). | | `currency` | string | no | Only `XOF` is supported (default). | | `dueDate` | ISO date | no | When the request is sent. Default: now → sent within a minute. | | `reminders` | ISO date[] | no | EXACT reminder dates, each strictly after `dueDate` and before `expiresAt`. **Omit for a one-off checkout — no reminder is ever sent.** Plan cap: Pro 3 · Max 5 · Enterprise 10 (`403 REMINDER_LIMIT_EXCEEDED`). | | `expiresIn` | integer | no | Checkout validity in **seconds from `dueDate`** (min 60, max 90 days). Unpaid at that point → status `expired`, pending sends cancelled, `payment_request.expired` fired. Omit → the request stays open forever. | | `expiresAt` | ISO date | no | Absolute expiry, alternative to `expiresIn` (same 60s–90d window). Sending both → `400 VALIDATION_ERROR`. | | `replaceExisting` | boolean | no | `true` → cancel the open request already held by this number this month instead of returning `409`. Cancelled ids come back in `data.replaced`. | | `webhooks.onSuccess` | url | no | Called when the request is paid (`payment_request.succeeded`). | | `webhooks.onExpired` | url | no | Called when the request expires unpaid (`payment_request.expired`). Requires `expiresIn`/`expiresAt`; defaults to the `onSuccess` URL. | | `externalRef` | string | no | Your internal id, echoed back in the webhook. | ```bash curl -X POST https://apifayko.peelo.chat/api/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://your-app.com/webhooks/faymaco" }, "externalRef": "order-4821" }' ``` Response `201`: ```json { "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"], "remindersSent": 0, "source": "api", "externalRef": "order-4821", "webhooks": { "onSuccess": "https://your-app.com/webhooks/faymaco" } }, "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" } ] } } ``` Rules: - `amountToPay` is what the customer actually pays. If the account is configured with `fees.mode = customer_pays`, Faymaco grosses up the charged amount so the merchant receives exactly `amount`. - One **active** request per (phone, calendar month). Duplicate → `409 DUPLICATE_REQUEST` with `details.existingPaymentRequestId`. **Requests created with `expiresIn`/`expiresAt` are exempt**: a checkout is repeatable, the same customer may buy several times a month. - Each request consumes one slot of the plan's monthly quota (same pool as the dashboard). Quota reached → `403 QUOTA_EXCEEDED`. An expired request does **not** give its slot back. - Statuses: `active` → `paid` | `overdue` (due date passed unpaid) | `cancelled` (closed on purpose) | `expired` (checkout validity elapsed unpaid — abandoned cart). ### One-off checkout (pay once, no reminder, auto-expiry) The pattern for a SaaS that charges a single payment and must not chase the customer: ```bash curl -X POST https://apifayko.peelo.chat/api/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://your-app.com/webhooks/faymaco", "onExpired": "https://your-app.com/webhooks/faymaco" }, "externalRef": "order-4821" }' ``` What each piece buys you: - **no `reminders`** → exactly one WhatsApp message, never resent. - **no `dueDate`** → sent immediately (within a minute). - **`expiresIn: 1800`** → 30 minutes to pay. Unpaid after that, Faymaco closes the request (`expired`), cancels what was still scheduled, and calls `onExpired` so you can release the order. The customer stops seeing it in their WhatsApp list — abandoned carts don't pile up. - **`replaceExisting: true`** → if the customer starts over, the previous link is cancelled and returned in `data.replaced` instead of a `409`. Timing: expiry is processed by the same one-minute cron as the sends, so `expired` lands within ~60s of `expiresAt` — treat `expiresAt` as the guarantee, not to-the-second precision. A payment that arrives before the job runs still wins: the request is already `paid`, and expiry skips it. Nothing to reconcile. ### List / retrieve / cancel payment requests - `GET /payment-requests` — query: `status` (`active`|`paid`|`overdue`|`cancelled`|`expired`), `source` (`api` to only see API-created ones; dashboard-created ones are included by default), `limit` (1–100, default 20), `cursor` (pagination via `data.pagination.nextCursor`). Subscriptions never appear here. `status=expired` lists the abandoned checkouts. - `GET /payment-requests/:id` — one request by id (`404 PAYMENT_REQUEST_NOT_FOUND`). - `POST /payment-requests/:id/cancel` — sets `cancelled`, stops pending send/reminders. Already paid, cancelled or expired → `400 INVALID_STATE`. This is the on-demand counterpart to `expiresIn`: cancel when your app decides, expire when the clock decides. ## Lifecycle & timing When the first WhatsApp message is sent depends on creation fields: | At creation | First message | |--------------------------|---------------| | no `startDate` | sent automatically, within a minute | | future `startDate` | sent on that date | | `startNextMonth: true` | sent on the 1st of next month | Each cycle, fully handled by Faymaco: 1. At the due date, a WhatsApp payment request (+ PDF invoice) is sent. 2. If unpaid, automatic reminders (default day+1, +3, +7). 3. On payment, the `subscription.payment.succeeded` webhook fires. 4. The due date advances one period and the next cycle starts. 5. If a due date passes unpaid (~10 days), the `subscription.payment.failed` webhook fires on `onExpired`. ## Webhooks Faymaco POSTs a JSON event to your `webhooks.onSuccess` / `webhooks.onExpired` URL. Events: | Event | When | |----------------------------------|------| | `subscription.payment.succeeded` | A subscription cycle was paid. | | `subscription.payment.failed` | Due date passed unpaid (cycle unpaid after ~10 days) → sent to `onExpired`. | | `payment_request.succeeded` | A one-off payment request was paid. | | `payment_request.expired` | A checkout (`expiresIn`/`expiresAt`) elapsed unpaid → sent to `onExpired`, or to `onSuccess` if no `onExpired` was set. | Headers sent with every webhook: | Header | Value | |------------------------|-------| | `X-Faymaco-Event` | event name | | `X-Faymaco-Timestamp` | unix seconds | | `X-Faymaco-Signature` | `t=,v1=` | Body received: ```json { "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" } } } ``` `payment_request.succeeded` body (`data` differs): ```json { "id": "evt_xxx", "event": "payment_request.succeeded", "created": "2026-08-20T10:12:00Z", "data": { "paymentRequestId": "6a7b...", "externalRef": "order-4821", "paidAt": "2026-08-20T10:12:00Z", "source": "platform", "customer": { "name": "Awa Diop", "phone": "+221770000000" }, "amount": 25000, "amountPaid": 25000, "currency": "XOF" } } ``` `data.source` is `platform` (paid via WhatsApp / Wave / Orange Money) or `manual` (the merchant marked it paid from the dashboard — cash, bank transfer…). `payment_request.expired` body — the abandoned-checkout signal, use it to release whatever you reserved for that order: ```json { "id": "evt_xxx", "event": "payment_request.expired", "created": "2026-08-20T10:30:00Z", "data": { "paymentRequestId": "6a7b...", "externalRef": "order-4821", "expiresAt": "2026-08-20T10:30:00Z", "expiredAt": "2026-08-20T10:30:12Z", "customer": { "name": "Awa Diop", "phone": "+221770000000" }, "amount": 25000, "currency": "XOF" } } ``` `succeeded` and `expired` are mutually exclusive for a given request: whichever state it reaches first is terminal, so you never get both for the same `externalRef`. Delivery & retries: respond `2xx` quickly (process async). On non-2xx or timeout (10s), Faymaco retries up to 3 times (backoff ~2s then ~10s). ### Verify the signature (required) `v1 = HMAC_SHA256(secret, ".")`, hex-encoded. The `secret` is the account **webhook secret** shown on the Developers page (also `GET /api/fayko/api-keys/webhook-secret`). It is shared, never sent in the request, and can be rotated. Verify the raw (unparsed) body. ```js const crypto = require("crypto"); function verifyFaymacoWebhook(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-replay: reject if older than 5 min if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const expected = crypto.createHmac("sha256", secret) .update(`${ts}.${req.rawBody}`) // req.rawBody = exact bytes received .digest("hex"); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)); } ``` ## Error codes Shape: `{ "success": false, "error": { "code": "...", "message": "..." } }`. | HTTP | code | Meaning | |------|------|---------| | 401 | `NO_API_KEY` / `INVALID_API_KEY` | Key missing, invalid, or revoked. | | 403 | `FEATURE_NOT_AVAILABLE` | Plan has no API access (Pro+ required). | | 403 | `QUOTA_EXCEEDED` | Monthly request quota reached; creation refused. `details: { used, limit, plan }`. | | 403 | `ACCOUNT_SUSPENDED` | Account suspended. | | 400 | `VALIDATION_ERROR` | Invalid request body. | | 400 | `INVALID_STATE` | Impossible status transition (e.g. cancelling a paid request). | | 400 | `INVALID_DATE` | `dueDate` in the past, a reminder date not strictly after `dueDate`, a reminder after `expiresAt`, or an expiry window outside 60s–90 days. | | 400 | `UNSUPPORTED_CURRENCY` | Only `XOF` is supported. | | 403 | `REMINDER_LIMIT_EXCEEDED` | Too many reminder dates for the plan. | | 404 | `SUBSCRIPTION_NOT_FOUND` | Unknown subscription. | | 404 | `PAYMENT_REQUEST_NOT_FOUND` | Unknown payment request. | | 409 | `DUPLICATE_REQUEST` | Active payment request already exists for this phone this month (`details.existingPaymentRequestId`). Resolve with `replaceExisting: true`, `POST /:id/cancel`, or `expiresIn` (checkouts are exempt). | | 409 | `IDEMPOTENCY_IN_PROGRESS` | An identical request is already in flight. | | 429 | `RATE_LIMITED` | Too many requests; retry after `Retry-After`. | ## Ready-to-use clients Drop-in single-file clients (subscriptions + one-off payment requests + webhook signature verification): - Node (zero dependencies): https://docs.fayma.co/faymaco.js - Python (`requests`): https://docs.fayma.co/faymaco.py ```js const { Faymaco } = require("./faymaco"); const fmc = new Faymaco({ apiKey: process.env.FAYMACO_API_KEY }); const { subscription } = await fmc.createSubscription({ customer: { name: "Awa Diop", phone: "+221770000000" }, amount: 5000, frequency: "monthly", webhooks: { onSuccess: "https://your-app.com/webhooks/faymaco" } }, { idempotencyKey: "order-12345" }); ``` ```python from faymaco import Faymaco fmc = Faymaco(api_key=os.environ["FAYMACO_API_KEY"]) data = fmc.create_subscription( customer={"name": "Awa Diop", "phone": "+221770000000"}, amount=5000, frequency="monthly", webhooks={"onSuccess": "https://your-app.com/webhooks/faymaco"}, idempotency_key="order-12345") ``` ## Minimal integration checklist (for an implementing agent) 1. Store the `fk_live_` key as a server-side secret. Never ship it to a browser/mobile client. 2. To onboard a subscriber: `POST /subscriptions` with `customer`, `amount`, `frequency`, and your `webhooks.onSuccess` / `webhooks.onExpired` URLs. **Always send an `Idempotency-Key`** (one stable value per logical operation, reused on every retry) — without it a double-submit creates a duplicate subscription and double-bills the customer. 3. To collect a one-off amount (invoice, order, deposit): `POST /payment-requests` with `customer`, `amount`, optional `dueDate`/`reminders`/`externalRef`, and `webhooks.onSuccess`. Same `Idempotency-Key` rule. 3b. For a **pay-once checkout** (no chasing the customer): same call, but omit `reminders`, add `expiresIn` (e.g. `1800` for 30 min) and `replaceExisting: true`. One message, and an abandoned request closes itself instead of staying in the customer's list forever. 4. Expose one HTTPS endpoint that accepts POST, reads the **raw body**, verifies the signature (code above), returns `2xx` fast, then processes `subscription.payment.succeeded` / `subscription.payment.failed` / `payment_request.succeeded` / `payment_request.expired` asynchronously (grant/revoke access, mark the order paid, or release a reserved order — use `data.externalRef` to match it). 5. Use the `GET` endpoints to reconcile state; `pause`/`resume`/`cancel` to manage lifecycle. 6. Handle `QUOTA_EXCEEDED` (429/403) by backing off and surfacing it to your ops.