Headless API
Build billing into your product
Create subscriptions from your backend, let MariFlow run renewals, retries and payment links, and hear about every change through signed webhooks.
Preview docs. The domain (mariflow.com) and the SDK package name (@mariflow/node) are placeholders until they're final.
Overview
There are two ways to bill with MariFlow, on the same engine: a hosted storefront that needs no code, and this API. You can use either or both; a customer is billed the same way whichever started the subscription. Besides subscriptions, you can take one-off payments through payment links (see the API reference).
- Base URL
https://api.mariflow.com- Sandbox and live
- Same host. Your key decides which one you're using.
- Money
- Integers in the currency's minor unit, VAT included where it applies. UGX has no minor unit, so USh 82,600 is 82600.
- Currencies
- UGX at launch (Uganda). KES, TZS, RWF, BIF, ETB, SSP and SOS follow.
Authentication
Send your secret key as a bearer token on every request. Keys starting sk_test_ work in the sandbox, where no real money moves; sk_live_ keys are issued once your business is verified.
Header
Authorization: Bearer sk_test_…A key is shown once, when it's created or rolled; MariFlow stores only a hash. When you roll a key you can keep the old one working for 24 hours while you deploy the new one, or revoke it straight away.
Idempotency
Every POST, PUT, PATCH and DELETE needs an Idempotency-Key header with a UUID you generate. If a request times out, send it again with the same key: you get the first request's result instead of a second charge. Keys are remembered for 24 hours per account.
If you retry while the first request is still running, you get 409. Wait for it to finish, then retry with the same key.
Create a subscription
Create a customer and a payment method first (see the API reference), then a subscription on one of the merchant's prices. It starts as incomplete while the first payment is confirmed.
Request
curl https://api.mariflow.com/v1/subscriptions \
-H "Authorization: Bearer $MARIFLOW_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-d customer_id=cus_42 \
-d price_id=price_pro_monthly \
-d payment_method_id=pm_cardFor a card, pass the returned client_secret to your frontend to finish 3D Secure if the card issuer asks. When the first payment succeeds, the subscription becomes active and you receive subscription.created.
Response (fields covered in these docs)
{
"id": "sub_5Jk8",
"customer_id": "cus_42",
"price_id": "price_pro_monthly",
"seats": 1,
"status": "incomplete",
"cancel_at_period_end": false,
"client_secret": "pi_…_secret_…",
"payment_method": {
"type": "card",
"last4": "4242"
},
"renewal": "automatic"
}The same subscription paid by mobile money has no client_secret (the customer approves on their phone instead), a payment_method of { "type": "mobile_money", "provider": "MTN MoMo", "last4": "7781" } and a renewal of standing_order or payment_link.
payment_method.typecardormobile_money.payment_method.provider- For mobile money in Uganda: MTN MoMo or Airtel Money.
payment_method.last4- The last four digits of the card or of the mobile money number.
renewalautomaticfor cards (charged each period),standing_orderfor mobile money with a standing order (collected automatically), orpayment_linkfor mobile money the customer approves each time.
Lifecycle
A subscription only moves along these transitions. Anything else is refused with 422.
| From | When | To | Events |
|---|---|---|---|
— | You create a subscription | incomplete | |
— | You create one with a free trial | trialing | subscription.created |
trialing | The trial ends and the first payment succeeds | active | subscription.trial_ending (3 days before)invoice.paid |
trialing | The first payment after the trial fails | past_due | invoice.payment_failedsubscription.past_due |
trialing | Canceled during the trial (nothing is charged) | canceled | subscription.canceled |
incomplete | The first payment succeeds | active | subscription.created |
active | A renewal is paid | active | invoice.paid |
active | A renewal isn't paid | past_due | invoice.payment_failedsubscription.past_due |
past_due | A retry, reminder link or new payment method pays it | active | invoice.paidsubscription.recovered |
past_due | Every retry or reminder goes unpaid | unpaid | subscription.unpaid |
unpaid | The customer pays a payment link (new period starts) | active | invoice.voidedinvoice.paidsubscription.recovered |
active | Canceled, now or at the end of the period | canceled | subscription.canceled |
past_due / unpaid | Canceled (the unpaid invoice is voided) | canceled | invoice.voidedsubscription.canceled |
Failed renewals are retried on the merchant's schedule (by default 1, 3 and 7 days after the first failure). After the last one, the subscription becomes unpaid, or canceled if the merchant chose that.
Mobile money and payment links
Money can't be taken from a phone without the customer's approval, so mobile money subscriptions renew by payment link. 3 days before each renewal the customer gets a link, approves the payment on their phone, and the subscription carries on.
- If the link isn't paid by the due date, the subscription becomes
past_due. On each retry day the customer gets a reminder with a fresh link instead of a charge. - Only the newest link for an invoice works. Each new link replaces the last, so a customer can't pay twice.
invoice.payment_link_createdtells you which link itreplaces. - A paused (
unpaid) subscription restarts when the customer pays a link: the unpaid invoice is voided and a new period starts that day.
Where the network offers standing orders (not yet available in Uganda; being confirmed with MTN and Airtel), mobile money can renew automatically instead. It's on by default: the customer approves the standing order together with the first payment, and renewal is standing_order. If a renewal fails, the usual reminder links take over. If the customer cancels the standing order on their phone, the subscription carries on by payment link and you receive subscription.updated.
MariFlow sends links by SMS and email. To deliver them yourself, in your app or on WhatsApp, turn that off under Webhooks, Payment link delivery in the developer dashboard, and send the url from invoice.payment_link_created.
Webhooks
MariFlow sends a POST to your endpoint for every event, signed in the MariFlow-Signature header. t is when it was sent (Unix seconds) and v1 is an HMAC-SHA256, in hex, of t, a full stop and the raw body, using your endpoint's signing secret.
Header
MariFlow-Signature: t=1727417640,v1=5f2b9c…e41aReject deliveries whose t is more than 5 minutes from your clock, so a captured webhook can't be replayed. While a rolled secret is still in its 24-hour overlap, the header carries one v1 per secret; accept the event if any of them matches.
webhooks.ts
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export async function POST(req: Request) {
const body = await req.text();
const header = req.headers.get("MariFlow-Signature") ?? "";
const parts = header.split(",").map((p) => p.split("="));
const t = Number(parts.find(([k]) => k === "t")?.[1]);
const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
// Reject old deliveries so a captured webhook can't be replayed later.
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) {
return new Response("Stale signature", { status: 400 });
}
const expected = createHmac("sha256", process.env.MARIFLOW_WEBHOOK_SECRET!)
.update(`${t}.${body}`)
.digest("hex");
// While a rolled secret overlaps, there's one v1 per secret.
const valid = signatures.some(
(s) => s.length === expected.length &&
timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
);
if (!valid) return new Response("Invalid signature", { status: 401 });
const event = JSON.parse(body);
// Skip event.id if you've handled it already: retries keep the same id.
return new Response(null, { status: 200 });
}- Respond
- With a
2xxwithin 5 seconds. Do slow work after responding. - Retries
- Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 24 hours.
- Dead letter
- After the last retry the event waits in the dead-letter queue. Replay it from the developer dashboard, one at a time or all at once.
- Duplicates
- Retries and replays keep the original
id, so record the ids you've processed and skip repeats.
Events
Every event has the same envelope: id, type, created_at and data. Subscribe an endpoint to all events or pick the ones you need.
subscription.createdThe subscription started: the first payment succeeded, or a free trial began. Show exampleHide example
{ "id": "evt_01J8P5…", "type": "subscription.created", "created_at": "2026-09-27T06:14:00Z", "data": { "subscription_id": "sub_4Hx3" } }subscription.trial_endingA free trial ends in 3 days. Payment-link customers get their link at the same time. Show exampleHide example
{ "id": "evt_01J8P5…", "type": "subscription.trial_ending", "created_at": "2026-09-27T06:14:00Z", "data": { "subscription_id": "sub_4Hx3", "trial_ends_at": "2026-10-05T00:00:00+03:00", "renewal": "payment_link" } }subscription.updatedPlan, seats, payment method, how it renews (e.g. a standing order turned on, off or cancelled on the phone) or a scheduled cancellation changed. Show exampleHide example
{ "id": "evt_01J8P5…", "type": "subscription.updated", "created_at": "2026-09-27T06:14:00Z", "data": { "subscription_id": "sub_4Hx3" } }subscription.past_dueA renewal wasn't paid. Retries (card) or reminder links (mobile money) begin. Show exampleHide example
{ "id": "evt_01J8P5…", "type": "subscription.past_due", "created_at": "2026-09-27T06:14:00Z", "data": { "subscription_id": "sub_4Hx3" } }subscription.recoveredA past_due subscription was paid, or an unpaid one restarted with a new period. Show exampleHide example
{ "id": "evt_01J8P5…", "type": "subscription.recovered", "created_at": "2026-09-27T06:14:00Z", "data": { "subscription_id": "sub_4Hx3" } }subscription.unpaidEvery retry or reminder went unpaid. Access is revoked. Show exampleHide example
{ "id": "evt_01J8P5…", "type": "subscription.unpaid", "created_at": "2026-09-27T06:14:00Z", "data": { "subscription_id": "sub_4Hx3" } }subscription.canceledThe subscription ended, immediately or at the end of its period. Show exampleHide example
{ "id": "evt_01J8P5…", "type": "subscription.canceled", "created_at": "2026-09-27T06:14:00Z", "data": { "subscription_id": "sub_4Hx3" } }invoice.paidAn invoice was paid: a renewal, a retry, a payment link or a restart. Show exampleHide example
{ "id": "evt_01J8P5…", "type": "invoice.paid", "created_at": "2026-09-27T06:14:00Z", "data": { "subscription_id": "sub_4Hx3", "invoice_id": "in_4Hx3_test" } }invoice.payment_failedA charge was declined, a retry failed, a mobile money request wasn't approved or a reminder went unpaid. Show exampleHide example
{ "id": "evt_01J8P5…", "type": "invoice.payment_failed", "created_at": "2026-09-27T06:14:00Z", "data": { "subscription_id": "sub_4Hx3", "invoice_id": "in_4Hx3_test", "attempt": 1, "reason": "card_declined", "next_attempt_at": "2026-09-29T09:00:00+03:00" } }invoice.payment_link_createdA payment link was issued: a renewal, a reminder or a merchant's recovery link. Show exampleHide example
{ "id": "evt_01J8P5…", "type": "invoice.payment_link_created", "created_at": "2026-09-27T06:14:00Z", "data": { "subscription_id": "sub_4Hx3", "invoice_id": "in_4Hx3_test", "url": "https://acme.mariflow.com/pay/pl_test_EXAMPLE", "replaces": null, "delivered_by": "mariflow" } }invoice.refundedMoney was refunded, in full or in part. Show exampleHide example
{ "id": "evt_01J8P5…", "type": "invoice.refunded", "created_at": "2026-09-27T06:14:00Z", "data": { "subscription_id": "sub_4Hx3", "invoice_id": "in_4Hx3_test", "amount_refunded": 106200, "fully_refunded": false, "refund": { "id": "re_test_EXAMPLE", "amount": 106200, "reason": "requested_by_customer", "destination": "card" } } }payment_link.paidA one-off payment link was paid: a service (reusable link) or a quoted job (one-time link). Show exampleHide example
{ "id": "evt_01J8P5…", "type": "payment_link.paid", "created_at": "2026-09-27T06:14:00Z", "data": { "link": "pl1_EXAMPLE", "kind": "custom", "description": "CV writing for Sarah Nambi", "amount": 40000, "currency": "UGX" } }invoice.voidedAn unpaid invoice was canceled, because the subscription was canceled or restarted. Show exampleHide example
{ "id": "evt_01J8P5…", "type": "invoice.voided", "created_at": "2026-09-27T06:14:00Z", "data": { "subscription_id": "sub_4Hx3", "invoice_id": "in_4Hx3_test", "reason": "subscription_canceled" } }
API reference
Every request needs your key; every POST and PATCH also needs an Idempotency-Key. Only the endpoints and fields below are part of the API.
Customers
The person or business you bill. Create one before their first subscription and reuse it.
POST/v1/customers
Create a customer.
- emailrequired
- Receipts, renewal notices and manage links go here.
- name
- Shown on invoices.
- phone
- In international format, e.g. +256772123456. Payment links go here by SMS.
GET/v1/customers/{id}
Get a customer.
GET/v1/customers
List customers, newest first.
- Only customers with this email.
- limit
- 1 to 100, default 20.
- starting_after
- An ID from the previous page, to get the next one.
Payment methods
Cards are entered in the browser in the payment processor's secure fields, which return a pm_ ID; card numbers never reach your server. Mobile money numbers are added from your backend.
POST/v1/payment_methods
Add a mobile money number for a customer.
- customer_idrequired
- The customer it belongs to.
- typerequired
- mobile_money
- providerrequired
- MTN MoMo or Airtel Money in Uganda.
- phonerequired
- In international format.
GET/v1/payment_methods
List a customer's payment methods.
- customer_idrequired
- The customer.
Subscriptions
A customer on a price. See Lifecycle for the statuses and what moves between them.
POST/v1/subscriptions
Create a subscription. It starts as incomplete until the first payment is confirmed.
- customer_idrequired
- The customer.
- price_idrequired
- From GET /v1/prices.
- payment_method_idrequired
- A card from the secure fields, or a mobile money method.
- seats
- For per-seat prices. Default 1.
- trial_days
- Free trial length, 0 to 90. Defaults to the plan's trial; 0 for none. The payment method is taken now and first charged when the trial ends.
- discount_code
- A discount code the merchant created. Refused if it's expired, used up or not for this plan.
- standing_order
- Mobile money only, where the network offers standing orders (none in Uganda yet): true by default, so the customer approves automatic renewals together with the first payment; false renews by payment link.
- Card: pass the returned client_secret to your frontend to finish 3D Secure if the issuer asks.
- Mobile money: the customer gets a request on their phone and the subscription becomes active once they approve it.
- With a trial, the subscription starts as trialing and nothing is charged: a card is saved, a standing order is approved, or a payment-link customer gets a link 3 days before the trial ends.
Sends subscription.created
GET/v1/subscriptions/{id}
Get a subscription.
GET/v1/subscriptions
List subscriptions, newest first.
- status
- incomplete, trialing, active, past_due, unpaid or canceled.
- customer_id
- Only this customer's.
- limit
- 1 to 100, default 20.
- starting_after
- An ID from the previous page, to get the next one.
PATCH/v1/subscriptions/{id}
Change the price, seats or payment method, or undo a scheduled cancellation.
- price_id
- Move to another price in the same currency.
- seats
- For per-seat prices.
- payment_method_id
- Card or mobile money. On a past_due subscription, the overdue invoice is charged to it straight away.
- cancel_at_period_end
- Send false to keep a subscription that was set to cancel at period end.
- discount_code
- Apply a discount to upcoming payments.
- standing_order
- false cancels a mobile money standing order, so renewals come by payment link; true asks the customer to approve one on their phone.
- Upgrades (a higher price or more seats) are charged now for the rest of the period, prorated.
- Downgrades apply from the next renewal; nothing is refunded automatically.
Sends subscription.updated, invoice.paid
POST/v1/subscriptions/{id}/cancel
Cancel a subscription.
- atrequired
- now, or period_end to keep access until the paid period ends (active subscriptions only).
- period_end sends subscription.updated now and subscription.canceled when the period ends.
- Canceling a past_due or unpaid subscription ends it now and voids the unpaid invoice.
- Canceling doesn't refund anything; refund an invoice separately if you want to.
Sends subscription.canceled, subscription.updated, invoice.voided
Invoices
One per billing period, and one for each prorated upgrade. Amounts include VAT where it applies.
GET/v1/invoices/{id}
Get an invoice.
GET/v1/invoices
List invoices, newest first.
- subscription_id
- Only this subscription's.
- status
- open, paid or void.
- limit
- 1 to 100, default 20.
- starting_after
- An ID from the previous page, to get the next one.
POST/v1/invoices/{id}/refunds
Refund a paid invoice, in full or in part.
- amount
- In minor units. Default: everything not yet refunded.
- reasonrequired
- requested_by_customer, duplicate, service_problem or other.
- The money goes back to how the customer paid, and comes out of your wallet. MariFlow' fee isn't returned.
- Refunds can't be undone.
Sends invoice.refunded
POST/v1/invoices/{id}/payment_links
Issue a new payment link for an open invoice, or to restart an unpaid subscription.
- Returns the link's url. Any earlier link for the invoice stops working.
- MariFlow sends it by SMS and email unless you deliver links yourself.
Sends invoice.payment_link_created
Payment links (one-off)
Get paid once, without a subscription. A service link is reusable for a fixed-price service; a custom link is for one customer and amount and closes once paid. VAT is added only if the merchant is VAT-registered.
POST/v1/payment_links
Create a one-off payment link. Returns its url to share by WhatsApp, SMS or email.
- kindrequired
- service (reusable) or custom (one-time).
- descriptionrequired
- What the customer is paying for, e.g. "CV writing for Sarah Nambi".
- amountrequired
- In minor units, before VAT.
- customer_id
- Custom links: the customer it's for.
Sends payment_link.paid
GET/v1/payment_links
List payment links, newest first.
- kind
- service or custom.
- state
- open or paid.
- limit
- 1 to 100, default 20.
- starting_after
- An ID from the previous page, to get the next one.
GET/v1/payment_links/{id}
Get a payment link, with how many times it was paid.
Prices
Read-only. Merchants create and edit plans and prices in the dashboard; use these to avoid hard-coding price IDs.
GET/v1/prices
List active prices.
- limit
- 1 to 100, default 20.
- starting_after
- An ID from the previous page, to get the next one.
GET/v1/prices/{id}
Get a price.
Events
The same events your webhooks receive. Use them to catch up if your endpoint was down.
GET/v1/events
List events, newest first.
- type
- Only this event type.
- limit
- 1 to 100, default 20.
- starting_after
- An ID from the previous page, to get the next one.
GET/v1/events/{id}
Get an event.
Pagination
Lists return up to limit items, newest first, with has_more. To get the next page, pass the last item's ID as starting_after.
Response
{
"data": [ { "id": "sub_5Rt1", … }, { "id": "sub_4Hx3", … } ],
"has_more": true
}Errors
Errors use the HTTP status and a body with a type, a code for the specific problem, a readable message and, for a bad field, the param it was about.
Response
{
"error": {
"type": "invalid_transition",
"code": "subscription_canceled",
"message": "canceled is a terminal state.",
"param": null
}
}400- The request is missing something, such as the
Idempotency-Keyheader or a required field. 401- The key is missing, wrong or was rolled. Send it as
Authorization: Bearer sk_…. 409- A request with this Idempotency-Key is still running. Wait, then retry with the same key.
422- The subscription can't make that change from its current state. Check the lifecycle above.
Testing
Use an sk_test_ key. The developer dashboard's sandbox tools fail the next renewal, succeed the next retry, send a payment link, refund the last payment or cancel, each sending the real webhooks. They exist in the sandbox only.
4242 4242 4242 4242- Card payment succeeds.
4000 0000 0000 0002- Card is declined.
4000 0027 6000 3184- Asks for 3D Secure, then succeeds.
…0000- Mobile number ending 0000: declined on the phone.
…1111- Mobile number ending 1111: no response in time.
- Any other number
- Mobile money payment is approved.
Use any future expiry date and any 3-digit security code with the test cards.
Going live
From the developer dashboard, request live access and verify your business: your business document, an ID for the person responsible and a proof of address. You get a decision within 48 hours, and live keys once you're approved. The sandbox is free; live payments cost 6% of each successful payment, with no setup or monthly fee.