Momentrax for developers

Connect a Momentrax business to a website, an accounting tool or anything else: read its records through the REST API, and get told the moment something happens through webhooks.

API: Premium plan Webhooks: Pro and Premium Version 1 · read-only 120 requests a minute per key

Getting started

The API gives read-only JSON access to one business's records. Make a key under Settings → API keys and send it with every request:

curl https://momentrax.com/api/v1/products?limit=5 \
  -H "Authorization: Bearer mtx_your_key_here"

A key is mtx_ followed by 40 letters and digits. It is shown once, when it is made; Momentrax keeps only a hash of it, so a lost key is revoked and replaced. A key reads the whole business and cannot change anything.

Base address: https://momentrax.com/api/v1. Everything is HTTPS; amounts are numbers in the record's currency, rounded to two decimals; times are ISO 8601 in the business's timezone; dates are YYYY-MM-DD. A field with nothing in it is null, not left out.

GET https://momentrax.com/api/v1/me tells you which business a key belongs to:

{
  "data": {
    "business": { "id": 4, "name": "Kamdini Hardware Ltd", "currency": "UGX", "timezone": "Africa/Kampala" },
    "resources": [ "products", "stock", "customers", "suppliers", "sales", "invoices", "payments", "expenses", "branches" ],
    "rate_limit_per_minute": 120
  }
}

Lists and pages

GET /{resource} returns up to limit records (default 50, most 100). Ask for the next page with page=2, or follow links.next until it is null:

{
  "data": [ … ],
  "meta":  { "page": 1, "limit": 50, "has_more": true },
  "links": { "next": "/api/v1/sales?from=2026-09-01&page=2&limit=50" }
}

Sales, invoices, payments and expenses come newest first; products, stock, customers, suppliers and branches in the order they were added. A filter the resource does not have is ignored.

GET /{resource}/{id} returns one record as {"data": {…}}. Sales and invoices include their items and payments only on this single-record call. stock and branches are lists only: asking for one by id answers 404.

Errors and limits

StatuscodeMeaning
400bad_requestA filter value is wrong: a date not written 2026-09-30, or an updated_since that is not a date or an ISO 8601 time.
401unauthorizedMissing, wrong or revoked key. Send it as Authorization: Bearer mtx_….
403plan, subscription_suspendedThe business's plan doesn't include the API, or its subscription is suspended.
404not_foundNo such resource or record, or a single record asked of stock or branches.
405method_not_allowedOnly GET is supported in this version.
429rate_limitedMore than 120 requests in one clock minute with one key. Wait for the seconds in Retry-After; the count starts again at the next minute.

Errors look like {"error": {"code": "not_found", "message": "No sale with that id."}}. Every answer to a request with a valid key carries X-RateLimit-Limit and X-RateLimit-Remaining (a 401 or 405 does not).

Resources

ResourceFiltersNotes
productsq (name, code or exact barcode), active, updated_sincecode, barcode, name, description, category, unit, price, sale_price, currency, tax (name and rate), reorder_level, stock (total and by_branch), is_active, and online (shown in the web shop). No cost prices.
stockbranch_id, product_id, low=trueOne row per product per branch: product_id, product_code, product, branch_id, branch, quantity, reorder_level, low, updated_at. low is true at or below the reorder level. List only.
customersq (name, email or phone), updated_sincebalance is what the customer owes now on sales and invoices. Also code, type, status, tax_number, contact, credit_limit.
suppliersq (name, email or phone), updated_sincebalance is what you owe them now. The same fields as a customer, without credit_limit.
salesfrom, to, customer_id, branch_id, status (paid, partially_paid, unpaid, overpaid)Till and direct sales, and paid web shop orders (channel says which). Quotations are left out. A voided sale stays in the list with voided_at and void_reason filled in. status filters on the sale's payment_status.
invoicesfrom, to, customer_id, status, payment_statusIssued invoices only (no drafts or proformas). number, date, due_date, status, payment_status, totals, voided_at. due is 0 for a waived invoice. Lines also carry tax_rate and tax.
paymentsfrom, to, direction (in, out)Money in and out: direction, amount, currency, date, method, reference, status, and the sale_id, invoice_id or expense_id it belongs to.
expensesfrom, to, category, supplier_idExpenses and supplier bills: number (the expense number), date, due_date, category, description, supplier, branch, currency, amount, tax, paid, due, payment_status.
branchesnoneThe business's shops or offices: name, code, is_main, is_active, address, phone, email. List only.

Example: one sale

GET /api/v1/sales/7440
{
  "data": {
    "id": 7440, "receipt_number": "S-1532", "date": "2026-09-29T14:22:23+03:00",
    "branch": { "id": 26, "name": "Gulu Main Store" },
    "customer": { "id": 131, "name": "Akello Betty" },
    "currency": "UGX", "subtotal": 276000.0, "discount": 0.0, "tax": 0.0,
    "total": 276000.0, "paid": 276000.0, "due": 0.0, "payment_status": "paid",
    "voided_at": null, "void_reason": null, "created_at": "2026-09-29T14:22:23+03:00",
    "items": [ { "product_id": 939, "service_id": null, "description": "Cement 50kg (42.5N)",
                 "quantity": 6.0, "unit_price": 38000.0, "discount": 0.0, "line_total": 228000.0 } ],
    "payments": [ { "id": 7542, "direction": "in", "amount": 276000.0, "currency": "UGX", "date": "2026-09-29",
                    "method": "Cash", "reference": null, "status": "completed",
                    "sale_id": 7440, "invoice_id": null, "expense_id": null, "created_at": "2026-09-29T14:22:23+03:00" } ],
    "verification_code": "K7QM-2XHD-9RPB",
    "channel": "till", "web_order_number": null,
    "delivery_method": null, "delivery_fee": null
  }
}

A line's description is the text on the line; a line saved without any reads as the name of its product or service, as on the receipt. A line has a product_id, a service_id, or neither.

verification_code is the code printed on the sale's receipt, and it is on sales in lists too. Anyone holding the receipt can type it at /verify on this site to check that the receipt is genuine. A sale paid in several payments has a receipt and a code for each; this is the code of the latest one. It is null for a sale with no payment yet, or whose receipt has no code yet. Invoices carry no verification_code.

channel (since 9 October 2026) is "web_shop" for a sale made when an order in your web shop was paid, and "till" for every other sale. A web shop sale carries the order's number in web_order_number (for example "ORD-20261009-0007"), has no customer (web customers are not saved as customers yet) and one payment by mobile money. Cancelling the order voids the sale: voided_at and void_reason are filled in and a sale.voided event is sent. Both fields are on sales in lists and in sale.created and sale.voided webhook events too.

delivery_method and delivery_fee (since 10 October 2026) say how a web shop order reaches its customer: "delivery" or "pickup" (the customer collects it from the shop), and the delivery fee charged, 0 when delivery was free or the order is collected. Both are null for a till sale. A delivery fee is also one of the sale's items: a line with no product_id and no service_id whose description names the area, for example "Delivery to Kampala central", with quantity 1. The sale's subtotal includes it, and its tax includes the tax on it. They are on sales in lists and in the sale.created and sale.voided events too.

Webhooks

Webhooks (Pro and Premium) push events to your server instead of you asking. Add an address under Settings → Webhooks and pick the events. The address must be a full https:// one, at most 500 characters, with no username or password in it, and must not point into a private network. Momentrax sends a POST with a JSON body within about a minute:

POST /hooks/momentrax HTTP/1.1
Content-Type: application/json
User-Agent: Momentrax-Webhooks/1.0
Momentrax-Event: sale.created
Momentrax-Delivery: evt_1842
Momentrax-Signature: t=1790700000,v1=5f1c…

{
  "id": "evt_1842",
  "type": "sale.created",
  "created_at": "2026-09-29T14:22:40+03:00",
  "business": { "id": 4, "name": "Kamdini Hardware Ltd" },
  "data": { … the same shape as GET /api/v1/sales/{id} … }
}

Answer with any 2xx status within 10 seconds; do slow work after answering. Redirects are not followed, so a 3xx counts as a failure. Otherwise Momentrax tries again after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours (eight attempts in all), then gives up. After 50 failed attempts in a row the webhook is switched off, until it is switched on again in Settings. Events that fall due while a webhook is off, or after the business's plan stops including webhooks, are skipped, not kept for later.

A retry carries the same body and the same id as the first attempt, with a new t and signature. The same event can occasionally arrive twice: use id (also in Momentrax-Delivery) to ignore repeats. created_at is when the message was first sent. The signing secret starts with whsec_; making a new one in Settings stops the old one at once.

Checking the signature

Momentrax-Signature holds a time (t) and v1, the HMAC-SHA256 of t + "." + the raw body with the webhook's signing secret. Compute it yourself and compare; also refuse messages more than 5 minutes old.

// PHP
$body   = file_get_contents('php://input');
$secret = getenv('MOMENTRAX_WEBHOOK_SECRET');           // whsec_…
preg_match('/t=(\d+),v1=([0-9a-f]{64})/', $_SERVER['HTTP_MOMENTRAX_SIGNATURE'] ?? '', $m);
$ok = $m && abs(time() - (int) $m[1]) < 300
         && hash_equals(hash_hmac('sha256', $m[1] . '.' . $body, $secret), $m[2]);
if (!$ok) { http_response_code(400); exit; }
$event = json_decode($body, true);
http_response_code(200);
// Node.js (Express: use express.raw({ type: 'application/json' }) for this route)
const crypto = require('crypto');
function verify(rawBody, header, secret) {
  const m = /t=(\d+),v1=([0-9a-f]{64})/.exec(header || '');
  if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(m[1] + '.' + rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}

Events

EventWhendata is
sale.createdA sale is recorded (till or direct sale)a sale
sale.voidedA sale is voideda sale
invoice.createdAn invoice is issuedan invoice
invoice.paidAn invoice is paid in fullan invoice
invoice.voidedAn invoice is voidedan invoice
payment.receivedMoney comes in (sale, invoice or deposit)a payment
expense.createdAn expense or supplier bill is enteredan expense
customer.createdA customer is addeda customer
customer.updatedA customer's name, contacts, status or credit limit changesa customer
product.createdA product is addeda product
product.updatedA product's name, code, price, tax or status changesa product
stock.changedStock of a product at a branch changesa stock row
stock.lowStock falls to or below the reorder levela stock row
pingYou press "Send a test event"{"message": "…"}

Sale and invoice events carry the record with its items and payments, and a sale its verification_code, channel, web_order_number, delivery_method and delivery_fee. stock.changed and stock.low carry the stock row described under stock above. payment.received is sent for money coming in only. If the record has been removed before the message goes out, nothing is sent.

product.updated, customer.updated and stock.changed are combined when a record changes several times before the message goes out; data always shows the record as it is when sent.

Records read through the API or sent by a webhook go to systems the business chooses; from there they are the business's and its developer's to protect. What Momentrax does with them while they are here is in the data processing agreement.

Keys and webhooks are managed after signing in, by someone who can manage the business's settings. Questions: info@neptunes.co.ug.