Skip to content

Invoices

An invoice asks one payer for one amount by one due date. Futa makes it one single-use payment link at a time, for the whole amount, and sends it to the payer by SMS, by WhatsApp, by both or by neither, then reminds the payer as the batch’s reminder policy says. Invoices go in batches: a key creates a batch, sends its invoices in chunks, reads what each chunk rejected, then sends the batch. In the sandbox every channel and participant is synthetic: no message reaches a phone and no invoice moves real money.

Two key scopes open the /v1 invoice routes: invoices:write creates, fills, sends and discards batches and runs the sandbox’s reminders, and invoices:read reads batches and invoices. Only an Owner or an Admin gives a key one of them; the sandbox’s bootstrap key and keys issued by Futa’s staff hold neither.

  • One amount, one currency, one due date. The amount is in integer minor units of its currency, USD or CDF, as every amount on /v1: cents for USD, and for CDF a multiple of 100, whole francs. 35000000 is 350,000 francs and 1500 is 15.00 dollars. A decimal amount is refused.
  • One payable link at a time. Each invoice is paid through a fixed, single-use link for its whole amount, and at most one of its links is payable at a time. A reminder after the due date carries a new link that replaces the first. links lists them in order, the first and each replacement, and payable_link_id names the payable one, null when none is. An invoice’s link is not one of your payment links: /v1/payment-links answers it 404.
  • Paid only when Futa says so. An invoice is PAID once any of its links was paid, as the switch answers it: GET /v1/invoices/{id} reads its payment live on each call. While an attempt is pending or uncertain it is VERIFYING (« En vérification »), neither paid nor failed. Never mark an invoice paid, or failed, before Futa’s answer says so: no count is inferred and no state is optimistic.
  • No webhook of its own. Each paid invoice’s payment raises payment.approved, as any payment does.
  1. Create it. POST /v1/invoice-batches with invoices:write and an Idempotency-Key names the batch’s label, its channels (SMS, WHATSAPP, both, or none: an empty list makes the links and sends no message), its reminder policy and how many invoices will arrive, at most 50,000. It answers 201 with the batch, RECEIVING, and its BI-… reference. Nothing is sent and no link exists before the batch is sent. GET /v1/invoice-batches/{id} reads a batch, and GET /v1/invoice-batches lists them newest first, 100 a page.

    {
    "label": "Frais du 1er trimestre",
    "channels": ["SMS", "WHATSAPP"],
    "reminders": "BEFORE_AND_ON_DUE_DATE",
    "expected_invoices": 2
    }
  2. Send its invoices in chunks. POST /v1/invoice-batches/{id}/invoices takes at most 1,000 invoices and 512 KiB a call. Each invoice has its line in the batch, from 1 to the number announced, each once; a reference of yours, which the payer’s page shows, so it must hold no payer data; its amount and currency; its due_date, a Kinshasa calendar day; and, optionally, the payer’s name and phone (E.164), a description and a customer_reference that stays with you and is never sent to the payer. With no description, the link and every message say « Facture » and the reference. A malformed invoice refuses the whole chunk, 400 INVALID_REQUEST.

    {
    "chunk": 1,
    "invoices": [
    {
    "line": 1,
    "reference": "ELV-2026-0417",
    "name": "Famille Tshibanda",
    "phone": "+243810000001",
    "amount": 35000000,
    "currency": "CDF",
    "due_date": "2026-11-30",
    "description": "Frais 1er trimestre"
    },
    {
    "line": 2,
    "reference": "ELV-2026-0418",
    "name": null,
    "phone": "+243810000002",
    "amount": 1500,
    "currency": "USD",
    "due_date": "2026-11-30",
    "description": null
    }
    ]
    }
  3. Read what each chunk rejected. The chunk answers its receipt: its lines, those ready, those rejected and every problem of the rejected ones, at most four a line. A reference that an earlier line of the batch or another of your invoices already holds, case aside, is INVOICE_REFERENCE_DUPLICATE; a due date outside the day after today to 90 days ahead in Kinshasa, SYNTHETIC, is INVOICE_DUE_DATE_OUT_OF_RANGE; an amount above your per-payment limit in force when the chunk arrives is MERCHANT_LIMIT_EXCEEDED; and no phone while the batch has a channel is PHONE_MISSING. A problem never repeats a name, a phone, a reference or a description. A rejected invoice is not sent. Once every announced invoice has arrived, the batch is READY.

    {
    "batch_id": "00000000-0000-4000-8000-000000000101",
    "chunk": 1,
    "lines": 2,
    "ready": 1,
    "rejected": 1,
    "problems": [
    {
    "line": 2,
    "column": null,
    "field": "reference",
    "code": "INVOICE_REFERENCE_DUPLICATE",
    "value": null
    }
    ]
    }
  4. Send it. POST /v1/invoice-batches/{id}/send at the batch’s version sends it, its messages in the language the body names, fr when it names none (ln, sw and en are the others). It answers 202 with the batch SENDING at once, then makes each ready invoice’s link and first message. Each invoice is judged once more, on the Kinshasa day of the send: one whose due date has left the range, whose amount is above the limit in force, or whose reference another open invoice has taken since is REFUSED, its refusal saying which. The batch is SENT once every invoice is created or refused and every first message has an outcome.

    {
    "expected_version": 3,
    "language": "fr"
    }
  5. Read its invoices. GET /v1/invoice-batches/{id}/invoices lists a batch’s invoices in line order, 100 a page, optionally of one state, of the group NOT_PAID (UNPAID, VERIFYING and EXPIRED) or of the group UNDELIVERED, or found by q, the start of a reference or a phone written in full, never a name. A list shows the last read of each payment; a page of a state may hold fewer invoices than it read, or none, and still carry a next_cursor: follow it until it is null. GET /v1/invoices/{id} reads one invoice, its payment live, its phone masked to its last four digits.

    {
    "id": "00000000-0000-4000-8000-000000000201",
    "batch_id": "00000000-0000-4000-8000-000000000101",
    "line": 1,
    "position": 1,
    "reference": "ELV-2026-0417",
    "customer_reference": null,
    "name": "Famille Tshibanda",
    "phone": "+243•••••0001",
    "amount": 35000000,
    "currency": "CDF",
    "due_date": "2026-11-30",
    "description": "Frais 1er trimestre",
    "state": "PAID",
    "refusal": null,
    "links": [
    {
    "id": "00000000-0000-4000-8000-000000000301",
    "code": "LK-7Q2M-K9TX",
    "sequence": 1,
    "status": "PAID",
    "created_at": "2026-11-04T09:00:00.104512Z",
    "expires_at": "2026-11-30T23:00:00Z",
    "cancelled_at": null,
    "paid_attempt": {
    "payment_id": "00000000-0000-4000-8000-000000000401",
    "reference": "FT-0000000000000000000000000000000A",
    "participant": "simulator",
    "approved_at": "2026-11-05T10:31:07.250031Z"
    }
    }
    ],
    "payable_link_id": null,
    "paid_at": "2026-11-05T10:31:07.250031Z",
    "delivery": {
    "state": "DELIVERED",
    "reason": null,
    "messages": [
    {
    "channel": "SMS",
    "kind": "REQUEST",
    "occurrence": 1,
    "state": "DELIVERED",
    "reason": null,
    "expiry_date": "2026-11-30",
    "sent_at": "2026-11-04T09:00:02.5Z",
    "updated_at": "2026-11-04T09:00:05Z"
    }
    ]
    },
    "created_at": "2026-11-04T09:00:00.104512Z",
    "updated_at": "2026-11-05T10:31:08Z"
    }
  6. Or discard it. POST /v1/invoice-batches/{id}/discard at the batch’s version discards a batch not yet sent (« Annuler »): it is DISCARDED, frees its references and no longer counts among your open batches. Nothing of it was ever sent, so nothing is cancelled.

    {
    "expected_version": 3
    }

A batch reads its counts per state and its totals per currency, USD then CDF, each currency once, never summed and never converted. A total can pass 2^53: read it as a 64-bit integer, never as a floating-point number. The TypeScript client reads an amount exactly up to 2^53 − 1 minor units; past that, it refuses the answer rather than round it, and refuses to send such an amount. To read such a body exactly, call the operation’s Raw method, getInvoiceBatchRaw for example, and parse response.raw.text() with a reader that keeps 64-bit integers. On a send or a discard, the server has already done the write when the client refuses its answer: read that answer raw, by sending the same request again under the same key with sendInvoiceBatchRaw or discardInvoiceBatchRaw, which replays it, or read the batch again with getInvoiceBatchRaw, then response.raw.text(); never send the write again under a new key.

State Meaning
RECEIVING Made through the API: its invoices are arriving.
MAPPING, VALIDATING Made from a file in the merchant portal: its columns are being matched, then its lines checked.
READY Every invoice has arrived; it can be sent or discarded.
SENDING Its links and first messages are being made. Beyond 20 of your batches being sent at once, SYNTHETIC, a batch waits here, in order.
SENT Every invoice is created or refused and every first message has an outcome.
SEND_FAILED Final. The switch refused the send’s request, and the send stopped: send_failure gives INVOICE_BATCH_SEND_FAILED, a correlation_id to quote to support and failed_at. Its links are closed and it is never reminded; once they are closed, each invoice reads PAID or VERIFYING as its payment stands, CANCELLED otherwise.
LAPSED Not sent seven days after its last change, SYNTHETIC. Nothing of it was ever sent.
DISCARDED Discarded before it was sent.

Each invoice has a payment state, state (« Statut »), and a delivery state apart, delivery.state (« Envoi »). A delivery failure never fails an invoice: it stays UNPAID.

State Meaning
READY Ready, its batch not yet sent.
REFUSED Not created at the send: refusal is INVOICE_DUE_DATE_OUT_OF_RANGE, INVOICE_REFERENCE_DUPLICATE or MERCHANT_LIMIT_EXCEEDED.
UNPAID Created, its payable link waiting for the payer.
VERIFYING Its newest link has a pending or uncertain attempt: neither paid nor failed.
PAID One of its links was paid; paid_at says when.
EXPIRED « Expirée »: its window after the due date ended unpaid, or, with no reminders, its link ended unpaid after the due date. It is reminded no more and frees its reference.
CANCELLED Its batch ended SEND_FAILED and none of its links was paid or is being verified: none can be paid again.

EXPIRED means the time to pay is over, not that a payment failed. A first link ends at the end of the due date D, or 720 hours after it was made if that comes first; under a reminder policy, the after-due reminder’s link ends on D+8 at 09:00 Kinshasa time. A paid or expired invoice frees its reference for a new invoice.

Delivery Meaning
NONE No message was asked: no channel, or the batch is not sent yet.
PENDING A message has no outcome yet: HELD, ACCEPTED or SENT.
DELIVERED A message reached the payer.
UNCONFIRMED A message was sent and no receipt came.
UNDELIVERED A message reached no one (INVALID_NUMBER, UNREACHABLE) or was never sent before its window closed (SEND_WINDOW_CLOSED).
NOT_SENT No message went: the channels refused it, a reminder’s new link passed your per-payment limit (MERCHANT_LIMIT_EXCEEDED), or its invoice was no longer remindable when it was due (NOT_REMINDABLE).

A batch’s reminders policy chooses them, and needs a channel: a policy other than NONE on a batch with no channel is 400 INVALID_REQUEST.

Policy Reminders
BEFORE_AND_ON_DUE_DATE « J−3 et jour d’échéance »: at 09:00 Kinshasa time three days before the due date and on it.
WEEKLY « Chaque semaine »: at 09:00 Kinshasa time every seven days from the first message until the due date.
NONE « Aucune »: no reminder.

Under either of the first two, one more reminder goes on D+1 at 09:00 with a new link that ends on D+8 at 09:00, SYNTHETIC. A reminder missed, by a late run or a held day, sends only the latest one due, never two.

  • The send window. Futa calls the channels from 08:00 to 19:55 Kinshasa time, and each message carries 20:00 that day as its last moment. A message due outside the window waits HELD with SEND_WINDOW and goes at the next opening.
  • The daily cap. At most 100,000 messages a merchant a Kinshasa day, SYNTHETIC: the rest wait HELD with DAILY_CAP for the next day’s opening, never refused.
  • Links before messages. Before a reminder or a held message goes, its invoice’s payment is read again and its link replaced if its window needs a new one, so a message always carries a link that lasts until the day it names, expiry_date. A held message whose invoice is paid, being verified, expired or cancelled by then never goes: it ends NOT_SENT with NOT_REMINDABLE.

In the merchant portal, « Relancer les non payées » reminds a batch’s unpaid invoices at once and « Renvoyer » sends one invoice again; the API has neither.

The sandbox runs the reminder schedule on demand, so a test needs no waiting. POST /v1/sandbox/invoice-reminders/run with a test key holding invoices:write and an Idempotency-Key runs it as of as_of, for your own batches only. Only the sandbox serves it.

  • as_of is an instant from now to 100 days ahead, SYNTHETIC; outside that, 400 INVALID_REQUEST.
  • Each invoice gets only its latest reminder due at or before as_of that has not run, recorded under its own key, so a run again records nothing twice.
  • The window and the cap are judged as of as_of: a message outside 08:00 to 19:55 Kinshasa time, or beyond the cap, is recorded HELD, and a later run whose as_of falls inside the window sends it. A run whose as_of is 19:50 or later holds all its messages so.
  • not_after. A message a run sends carries 20:00 Kinshasa time on as_of’s day as its last moment.
  • Links end as the invoice says, never as as_of says: a link lasts at most 720 hours from the moment it is made, so a run far ahead may get a link that ends sooner than its schedule.

It answers what it recorded, labelled synthetic: the batches it looked at, the reminders it recorded and the invoices it found EXPIRED.

{
"as_of": "2026-11-27T08:00:00Z"
}
{
"as_of": "2026-11-27T08:00:00Z",
"synthetic": true,
"batches": 1,
"reminders": 2,
"expired": 0
}

Every invoice write takes an Idempotency-Key of 8 to 128 unaccented letters, digits, _ and -, scoped to the merchant and the key that sends it:

  • A batch’s creation and a reminder run. The same key with the same body answers what it made, whatever happened since; another body answers 409 IDEMPOTENCY_CONFLICT.
  • A send and a discard each cover one batch at the version sent: an exact retry replays the first answer, and the key used for another batch or body answers 409 IDEMPOTENCY_CONFLICT.
  • A chunk is known by its batch and its number rather than by its key: the same chunk sent again changes nothing and answers its receipt again, whatever its key; other invoices under a number already received answer 409 IDEMPOTENCY_CONFLICT.
  • A lost reply, a timeout or 503 SWITCH_UNAVAILABLE leaves you without the answer: send the same request again under the same key with the same body, never under a new key. A chunk that met 503 was kept nowhere and is judged afresh; a send that met it made nothing and its batch stays READY at its version; a run that met it stopped where it was and sends nothing twice.

An invoice route’s own refusals answer MerchantInvoiceError: a stable code to branch on, with Futa’s English and French messages. The others answer the Error body of the Errors page: on every invoice route, 400 INVALID_REQUEST from the framework, 401 INVALID_API_KEY, 403 INSUFFICIENT_SCOPE, 429 RATE_LIMITED and 500 INTERNAL_ERROR, and on each write 413 REQUEST_TOO_LARGE past 16 KiB, or 512 KiB for a chunk, and 415 UNSUPPORTED_MEDIA_TYPE. A request carrying an X-Futa- header, which only Futa’s own services send, is refused 400 before anything is kept.

{
"error": {
"code": "INVOICE_BATCH_LIMIT_REACHED",
"message_en": "You already have 20 batches in preparation: send or discard one before creating another.",
"message_fr": "Vous avez déjà 20 lots en préparation : envoyez-en ou annulez-en un avant d'en créer un autre."
}
}
Route Its own refusals
POST /v1/invoice-batches 400 INVALID_REQUEST; 409 INVOICE_BATCH_LIMIT_REACHED: your open batches, MAPPING, VALIDATING, RECEIVING or READY, are at their bound of 20, SYNTHETIC; 409 MESSAGES_NOT_CONFIGURED: a channel chosen outside the sandbox; 409 IDEMPOTENCY_CONFLICT
GET /v1/invoice-batches 400 INVALID_REQUEST; 404 INVOICE_BATCH_NOT_FOUND: the cursor is none of your batches
GET /v1/invoice-batches/{id} 400 INVALID_REQUEST; 404 INVOICE_BATCH_NOT_FOUND; 503 SWITCH_UNAVAILABLE: the read changed nothing, read again
POST /v1/invoice-batches/{id}/invoices 400 INVALID_REQUEST, a chunk numbered beyond the invoices announced or numbering a line twice among them; 404 INVOICE_BATCH_NOT_FOUND; 409 INVOICE_BATCH_TOO_LARGE: a line beyond the invoices announced, or received in another chunk; 409 INVOICE_BATCH_CHANGED: the batch takes no more invoices; 409 IDEMPOTENCY_CONFLICT; 503 SWITCH_UNAVAILABLE
GET /v1/invoice-batches/{id}/invoices 400 INVALID_REQUEST; 404 INVOICE_BATCH_NOT_FOUND; 404 INVOICE_NOT_FOUND: the cursor is none of the batch’s invoices
POST /v1/invoice-batches/{id}/send 400 INVALID_REQUEST; 404 INVOICE_BATCH_NOT_FOUND; 409 INVOICE_BATCH_NOT_VALIDATED: its invoices have not all arrived, a file’s mapping is not confirmed, or none is ready; 409 INVOICE_BATCH_CHANGED: its version is not the one sent, or it was sent, discarded or has lapsed; 409 MESSAGES_NOT_CONFIGURED; 409 MERCHANT_NAME_INVALID: your stored name fails the text rule, nothing made; 409 IDEMPOTENCY_CONFLICT; 503 SWITCH_UNAVAILABLE
GET /v1/invoices/{id} 400 INVALID_REQUEST; 404 INVOICE_NOT_FOUND; 503 SWITCH_UNAVAILABLE: the read changed nothing, read again
POST /v1/sandbox/invoice-reminders/run 400 INVALID_REQUEST, also for an as_of outside now to 100 days ahead; 409 IDEMPOTENCY_CONFLICT; 503 SWITCH_UNAVAILABLE
POST /v1/invoice-batches/{id}/discard 400 INVALID_REQUEST; 404 INVOICE_BATCH_NOT_FOUND; 409 INVOICE_BATCH_CHANGED: its version is not the one sent, or it is being sent, was sent, failed to send, has lapsed or was discarded; 409 IDEMPOTENCY_CONFLICT

Another merchant’s batch or invoice answers 404, as an unknown one does. Only SWITCH_UNAVAILABLE asks you to send the request again, under the same key; no refusal invites a retry under another key.

Every limit below is the contract’s; those marked SYNTHETIC are sandbox choices, not production values.

  • A batch holds at most 50,000 invoices, sent in chunks of at most 1,000 invoices and 512 KiB; any other body is read within 16 KiB.
  • Open batches, those MAPPING, VALIDATING, RECEIVING or READY: at most 20 a merchant, SYNTHETIC.
  • A due date is a Kinshasa day from the day after the check to 90 days ahead, SYNTHETIC.
  • A batch not sent lapses seven days after its last change, SYNTHETIC.
  • Messages: at most 100,000 a merchant a Kinshasa day, SYNTHETIC; the after-due link ends on D+8 at 09:00, SYNTHETIC.
  • Texts: a label or a name at most 100 code points and a description at most 200, once trimmed of spaces, with no control, invisible or line-separating character; a reference of at most 64 unaccented letters, digits, _, ., / and -, starting with a letter or a digit.
  • Retention: an invoice’s personal fields, name, phone, description and customer_reference, are purged 90 days after it closes, PAID, EXPIRED, CANCELLED or REFUSED, and the invoice is kept without them; a lapsed or discarded batch is purged 90 days after it closes. Both SYNTHETIC.
  • Fees: an invoice shows none.

In the sandbox, messages go through synthetic SMS and WhatsApp channels and reach no phone; no transport or sender is named yet. Outside the sandbox, a batch that chooses a channel is refused 409 MESSAGES_NOT_CONFIGURED. A batch with no channel still makes its links and sends no message.

  • Live SMS and WhatsApp. Channels are synthetic until a transport is chosen.
  • Files. Uploading a file of your own columns, the results workbook and the printable QR sheet are in the merchant portal only; the API takes invoices as JSON chunks.
  • Reminding by hand. « Relancer les non payées » and « Renvoyer » are in the merchant portal only.
  • Real participants. A payment of an invoice goes through the sandbox’s synthetic participants only; no provider, biller or bank is named.
  • An invoice webhook. None: each paid invoice’s payment raises payment.approved.