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.
Scopes
Section titled “Scopes”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.
What an invoice is
Section titled “What an invoice is”- 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.35000000is 350,000 francs and1500is 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.
linkslists them in order, the first and each replacement, andpayable_link_idnames the payable one,nullwhen none is. An invoice’s link is not one of your payment links:/v1/payment-linksanswers it404. - Paid only when Futa says so. An invoice is
PAIDonce 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 isVERIFYING(« 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.
Send a batch through the API
Section titled “Send a batch through the API”-
Create it.
POST /v1/invoice-batcheswithinvoices:writeand anIdempotency-Keynames 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 answers201with the batch,RECEIVING, and itsBI-…reference. Nothing is sent and no link exists before the batch is sent.GET /v1/invoice-batches/{id}reads a batch, andGET /v1/invoice-batcheslists them newest first, 100 a page.{"label": "Frais du 1er trimestre","channels": ["SMS", "WHATSAPP"],"reminders": "BEFORE_AND_ON_DUE_DATE","expected_invoices": 2} -
Send its invoices in chunks.
POST /v1/invoice-batches/{id}/invoicestakes at most 1,000 invoices and 512 KiB a call. Each invoice has itslinein the batch, from 1 to the number announced, each once; areferenceof yours, which the payer’s page shows, so it must hold no payer data; its amount and currency; itsdue_date, a Kinshasa calendar day; and, optionally, the payer’snameandphone(E.164), adescriptionand acustomer_referencethat 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}]} -
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, isINVOICE_DUE_DATE_OUT_OF_RANGE; an amount above your per-payment limit in force when the chunk arrives isMERCHANT_LIMIT_EXCEEDED; and no phone while the batch has a channel isPHONE_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 isREADY.{"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}]} -
Send it.
POST /v1/invoice-batches/{id}/sendat the batch’sversionsends it, its messages in the language the body names,frwhen it names none (ln,swandenare the others). It answers202with the batchSENDINGat 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 isREFUSED, itsrefusalsaying which. The batch isSENTonce every invoice is created or refused and every first message has an outcome.{"expected_version": 3,"language": "fr"} -
Read its invoices.
GET /v1/invoice-batches/{id}/invoiceslists a batch’s invoices in line order, 100 a page, optionally of onestate, of the groupNOT_PAID(UNPAID,VERIFYINGandEXPIRED) or of the groupUNDELIVERED, or found byq, 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 anext_cursor: follow it until it isnull.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"} -
Or discard it.
POST /v1/invoice-batches/{id}/discardat the batch’s version discards a batch not yet sent (« Annuler »): it isDISCARDED, 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.
Batch states
Section titled “Batch states”| 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. |
Invoice states
Section titled “Invoice states”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). |
Reminders
Section titled “Reminders”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
HELDwithSEND_WINDOWand goes at the next opening. - The daily cap. At most 100,000 messages a merchant a Kinshasa day, SYNTHETIC: the rest wait
HELDwithDAILY_CAPfor 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 endsNOT_SENTwithNOT_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 reminder run
Section titled “The sandbox reminder run”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_ofis 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_ofthat 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 recordedHELD, and a later run whoseas_offalls inside the window sends it. A run whoseas_ofis 19:50 or later holds all its messages so. not_after. A message a run sends carries 20:00 Kinshasa time onas_of’s day as its last moment.- Links end as the invoice says, never as
as_ofsays: 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}Idempotency
Section titled “Idempotency”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_UNAVAILABLEleaves 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 met503was kept nowhere and is judged afresh; a send that met it made nothing and its batch staysREADYat its version; a run that met it stopped where it was and sends nothing twice.
Refusals
Section titled “Refusals”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.
Limits and retention
Section titled “Limits and retention”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,RECEIVINGorREADY: 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,descriptionandcustomer_reference, are purged 90 days after it closes,PAID,EXPIRED,CANCELLEDorREFUSED, 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.
Delivery
Section titled “Delivery”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.
Not available yet
Section titled “Not available yet”- 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.