Overview
The hub API
One REST surface for filing CAC registrations and DUNS numbers. You sell at your price; the hub files, follows up, and returns the certificate. Nothing in this API takes money from your customers. Settlement is a bank transfer you make against a reference the order gives you.
http://hub.flexibletechlab.com/api100000x-api-key or Bearer tokenQuick start
- Read the catalogue:
GET /api/products. - Quote before charging anyone:
POST /api/quote. - Create the order with its documents:
POST /api/orders. - Transfer
settlementKobo, quotinghubRef. - Wait for
order.settled(webhook), then fororder.completed.
/api/ping answers; with your key you are reading and writing real
orders for your tenant. Use a fresh instanceRef for anything you create.
Authentication
Send your key on every request
We issue one API key per platform and the console can rotate it. Send it in
x-api-key; Authorization: Bearer <key> is also
accepted. Treat it as a password: server-side only, never in a mobile app that a
customer can inspect.
curl http://hub.flexibletechlab.com/api/products \
-H "x-api-key: $HUB_API_KEY"
A missing or wrong key answers 401 with
{"ok": false, "error": "…"}. A key belonging to a suspended tenant
answers 403 with the reason.
Errors
One shape for every failure
Failures are {"ok": false, "error": "…"}. When the problem is with
specific fields, an errors array names each one, and the order is not
created.
{
"ok": false,
"error": "Please correct the highlighted fields",
"errors": [
"Company name is required",
"At least one director is required"
]
}
Rate limits
Each key may make 120 requests a minute. Past that you get
429 with retryAfter seconds and a
Retry-After header. Every response also carries
X-Request-Id; quote it when you report a problem.
/api/ping
No key
Health probe. Use it for uptime checks; it never touches the database.
{ "ok": true, "at": "2026-11-08T09:03:32.737Z" }
/api/products
Key
The catalogue you can sell: prices, turnaround, and the form and document spec for
each product. Render your forms from fields and
documents so a product change reaches your app without a release.
| Field | Meaning |
|---|---|
wholesaleKobo | What the hub charges you, in kobo. |
commissionPercent | The commission the hub grants you on that price. |
settlementKobo | What you actually transfer for one order. |
priceVersion | Bumped when the price changes; quote against the version you charged. |
fields | The form: key, label, type, required. |
documents | The file slots: key, label, required. |
{
"ok": true,
"products": [{
"code": "cac-limited",
"name": "CAC Limited Company",
"turnaround": "5-10 working days",
"wholesaleKobo": 12000000,
"commissionPercent": 5,
"commissionKobo": 600000,
"settlementKobo": 11400000,
"priceVersion": 3,
"classification": "2",
"fields": [{ "key": "companyName", "label": "Company name", "type": "text", "required": true }],
"documents": [{ "key": "company_meansOfIdentification", "label": "Means of identification", "required": true }]
}]
}
/api/quote
Key
Price a sale before you debit anyone. It returns the current
wholesale price, your commission, the exact settlement, and the form spec. Pass
retailKobo to record your margin on the order.
| Body | Type | |
|---|---|---|
product | string | Product code from the catalogue. Required. |
retailKobo | integer | What you will charge your customer, in kobo. |
{
"ok": true,
"quote": {
"product": { "code": "cac-limited", "name": "CAC Limited Company" },
"priceVersion": 3,
"wholesaleKobo": 12000000,
"commissionPercent": 5,
"commissionKobo": 600000,
"settlementKobo": 11400000,
"retailKobo": 15000000,
"marginKobo": 3600000,
"currency": "NGN",
"turnaround": "5-10 working days",
"fields": [],
"documents": [],
"payment": { "bank": { "name": null, "accountNumber": null, "accountName": null }, "note": "…" }
}
}
/api/orders
Key
Files an application and returns the reference you pay against. Idempotent on
instanceRef: a retry after a timeout returns the original order with
duplicate: true instead of filing twice.
| Body (payload) | Type | |
|---|---|---|
product | string | Product code. Required. |
instanceRef | string | Your idempotency key, unique per order. Required. |
retailKobo | integer | What you charged your customer. |
customer | object | name, email, phone. |
data | object | The answers, keyed exactly as the product's fields. |
businessType | string | The classification/type id, from /api/cac/reference. |
addons | array | [{ "code", "quantity" }], priced by the hub. |
notes | string | Anything the filer should know. Optional. |
Documents are multipart fields named by the product's
documents[].key: one field per file, repeat it for slots that accept
several. A JSON body works for quotes and pure-data products, but a product with
required documents will be refused until they are uploaded.
curl -X POST http://hub.flexibletechlab.com/api/orders \
-H "x-api-key: $HUB_API_KEY" \
-F 'payload={
"product": "duns",
"instanceRef": "duns-1042",
"retailKobo": 15000000,
"customer": { "name": "Ada Obi", "email": "ada@example.test", "phone": "+2348012345678" },
"data": { "legalBusinessName": "Ada Obi Ventures", "businessEmail": "hello@adaobi.test" }
}' \
-F 'cacCertificate=@cac-certificate.pdf' \
-F 'statusReport=@status-report.pdf'
{
"ok": true,
"order": {
"hubRef": "HUB-7F2K9QX",
"instanceRef": "duns-1042",
"status": "awaiting_settlement",
"product": { "code": "duns", "name": "DUNS Number" },
"wholesaleKobo": 15000000,
"commissionKobo": 750000,
"settlementKobo": 14250000,
"retailKobo": 15000000,
"priceVersion": 3,
"certificateAvailable": false,
"createdAt": "2026-11-08T09:03:32.737Z"
},
"payment": {
"amountKobo": 14250000,
"narration": "HUB-7F2K9QX",
"bank": { "name": "Your bank", "accountNumber": "0000000000", "accountName": "CAC & DUNS Hub" },
"note": "Transfer the exact amount, quoting the narration. The order starts once the transfer is confirmed."
}
}
What can go wrong
| Status | When | Body |
|---|---|---|
400 | Fields or documents missing, or a type that does not belong to the product | error + errors[] |
404 | Unknown product code | error |
409 | Product not on sale, or has no price | error |
200 | The instanceRef was already used, so nothing new is filed | duplicate: true |
/api/orders/:instanceRef
Key
One order, by the reference you chose. While it is
awaiting_settlement the response also carries the payment block;
after that it carries whatever the filer has told us.
/api/orders/:instanceRef/certificate
Key
The certificate itself, streamed as a file, once the order is completed. Instances
that would rather pull than wait for a webhook use this. 404 until
there is one.
curl -L http://hub.flexibletechlab.com/api/orders/duns-1042/certificate \
-H "x-api-key: $HUB_API_KEY" \
-o certificate.pdf
/api/orders/:instanceRef/cancel
Key
Cancels an unpaid order. Once the transfer is confirmed the order cannot be cancelled from your side. Past that point the conversation is about a refund, not a cancellation.
| Status | When |
|---|---|
200 | Cancelled; returns the order. |
404 | No order with that reference. |
409 | It is already paid, filed, or closed. |
/api/orders/:instanceRef/resubmit
Key
The customer's answer to a request from the hub. Same multipart shape as creating
an order, but every field is optional. A request is usually about one document.
Only valid while the order is awaiting_customer; it returns to
in_progress with no second payment.
curl -X POST http://hub.flexibletechlab.com/api/orders/duns-1042/resubmit \
-H "x-api-key: $HUB_API_KEY" \
-F 'payload={ "data": { "legalBusinessName": "Ada Obi Ventures Ltd" } }' \
-F 'cacCertificate=@clearer-scan.pdf'
/api/cac/reference
Key
The registrar's option lists (business types per classification, categories, natures of business, countries, states and LGAs), plus the add-ons the hub prices. Render your forms from this rather than hard-coding ids the portal would reject.
/api/payout-destination
Key
Where this tenant pays the wholesale amount. Hub-owned and read-only from your side: show it to your operator, never let them edit it.
{
"ok": true,
"destination": {
"bankName": "OPay Digital Services Limited (OPay)",
"accountNumber": "6471856354",
"accountName": "Flexible Tech Lab"
}
}
Behaviour
Order lifecycle
An order only moves forward, and only along the edges below. Anything else is
refused with 409 rather than quietly accepted.
| Status | What it means | Who moves it |
|---|---|---|
awaiting_settlement | Created, not paid. Nothing is filed. | You, by transferring the settlement |
active | Money confirmed; with the filer. | Hub (bank webhook or console) |
in_progress | Being filed with the registrar. | Hub |
awaiting_customer | The registrar asked for something; requestNote says what. | Hub, then you by resubmitting |
completed | Done; the certificate is available. | Hub |
rejected | Refused, with rejectReason. Refundable from the console. | Hub |
cancelled | Unpaid and abandoned. | You (before payment) |
refunded | Settlement returned. | Hub |
Behaviour
Webhooks
Set one URL in the console and the hub POSTs every state change to it. Delivery is queued, retried with backoff and visible in the console, so a lost event delays a notification, never the filing.
| Event | Fires when |
|---|---|
order.created | The order exists and is awaiting settlement. |
order.settled | The transfer was confirmed; work starts. |
order.in_progress | Filed with the registrar (also after a resubmission). |
order.awaiting_customer | Something is needed from the customer. |
order.completed | Finished; the certificate is available. |
order.rejected | Refused, with a reason. |
order.cancelled | You cancelled it before payment. |
order.refunded | The settlement was returned. |
Every delivery is signed: X-Hub-Signature is the HMAC-SHA256 of the
raw body using your webhook secret. Verify it before acting, and answer
2xx quickly. The payload is the order, not a transaction to process
inline.
const crypto = require("crypto");
const expected = crypto
.createHmac("sha256", process.env.HUB_WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
if (expected !== req.headers["x-hub-signature"]) return res.sendStatus(401);
Behaviour
Bank confirmation
The hub confirms a settlement when a bank credit notification carries the order's
hubRef in the narration. That endpoint is for a bank-alert pipe, not
for instances: it is authenticated by a shared secret, and a transfer it cannot
match is parked, not discarded. The console asks a human which
order it belongs to.
curl -X POST http://hub.flexibletechlab.com/api/bank-webhook \
-H "x-bank-secret: $BANK_WEBHOOK_SECRET" \
-H "Content-Type: application/json" \
-d '{ "amountKobo": 14250000, "reference": "HUB-7F2K9QX transfer", "bankReference": "FT2609..." }'
Reference
Status codes
| Code | Meaning |
|---|---|
200 | OK. On order creation it also means “already existed”: duplicate: true. |
201 | Created. |
400 | Bad input. errors[] names what to fix. |
401 | Missing or wrong API key / bank secret. |
403 | Tenant suspended, with the reason. |
404 | Unknown product, order, or no certificate yet. |
409 | State refuses the action (not on sale, already paid, wrong status). |
429 | Rate limited. Wait retryAfter seconds. |
500 | Our fault. Quote the X-Request-Id from the response. |
Onboarding is by hand. One WhatsApp message and we will set your tenant up.