MariFlow

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_card

For 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.type
card or mobile_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.
renewal
automatic for cards (charged each period), standing_order for mobile money with a standing order (collected automatically), or payment_link for mobile money the customer approves each time.

Lifecycle

A subscription only moves along these transitions. Anything else is refused with 422.

Subscription transitions and the events they send
FromWhenToEvents
—You create a subscriptionincomplete
—You create one with a free trialtrialingsubscription.created
trialingThe trial ends and the first payment succeedsactivesubscription.trial_ending (3 days before)invoice.paid
trialingThe first payment after the trial failspast_dueinvoice.payment_failedsubscription.past_due
trialingCanceled during the trial (nothing is charged)canceledsubscription.canceled
incompleteThe first payment succeedsactivesubscription.created
activeA renewal is paidactiveinvoice.paid
activeA renewal isn't paidpast_dueinvoice.payment_failedsubscription.past_due
past_dueA retry, reminder link or new payment method pays itactiveinvoice.paidsubscription.recovered
past_dueEvery retry or reminder goes unpaidunpaidsubscription.unpaid
unpaidThe customer pays a payment link (new period starts)activeinvoice.voidedinvoice.paidsubscription.recovered
activeCanceled, now or at the end of the periodcanceledsubscription.canceled
past_due / unpaidCanceled (the unpaid invoice is voided)canceledinvoice.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.

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…e41a

Reject 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 2xx within 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 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 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 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 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 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 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 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 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 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 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 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 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 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.

email
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

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-Key header 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.