CAC & DUNS Hub API reference
no key
API reference

Getting started

Overview Authentication Errors and rate limits

Endpoints

GET /api/ping GET /api/products POST /api/quote POST /api/orders GET /api/orders/:ref GET /orders/:ref/certificate POST /orders/:ref/cancel POST /orders/:ref/resubmit GET /api/cac/reference GET /api/payout-destination

Behaviour

Order lifecycle Webhooks Bank confirmation Status codes

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.

Base URLhttp://hub.flexibletechlab.com/api
FormatJSON, UTF-8; multipart for documents
MoneyKobo, integers. ₦1,000 = 100000
Authx-api-key or Bearer token

Quick start

  1. Read the catalogue: GET /api/products.
  2. Quote before charging anyone: POST /api/quote.
  3. Create the order with its documents: POST /api/orders.
  4. Transfer settlementKobo, quoting hubRef.
  5. Wait for order.settled (webhook), then for order.completed.
Test safely. Every request on this page is live. Without a key only /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
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.

422-style refusal
{
  "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.

GET /api/ping No key

Health probe. Use it for uptime checks; it never touches the database.

200
{ "ok": true, "at": "2026-11-08T09:03:32.737Z" }
Try itlive
GET /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.

FieldMeaning
wholesaleKoboWhat the hub charges you, in kobo.
commissionPercentThe commission the hub grants you on that price.
settlementKoboWhat you actually transfer for one order.
priceVersionBumped when the price changes; quote against the version you charged.
fieldsThe form: key, label, type, required.
documentsThe file slots: key, label, required.
200 · one product shown
{
  "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 }]
  }]
}
Try itlive
POST /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.

BodyType
productstringProduct code from the catalogue. Required.
retailKobointegerWhat you will charge your customer, in kobo.
200
{
  "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": "…" }
  }
}
Try itlive · use a real product code
POST /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
productstringProduct code. Required.
instanceRefstringYour idempotency key, unique per order. Required.
retailKobointegerWhat you charged your customer.
customerobjectname, email, phone.
dataobjectThe answers, keyed exactly as the product's fields.
businessTypestringThe classification/type id, from /api/cac/reference.
addonsarray[{ "code", "quantity" }], priced by the hub.
notesstringAnything 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.

multipart · the real shape
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'
201 Created
{
  "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

StatusWhenBody
400Fields or documents missing, or a type that does not belong to the producterror + errors[]
404Unknown product codeerror
409Product not on sale, or has no priceerror
200The instanceRef was already used, so nothing new is filedduplicate: true
Try itJSON body only · documents need multipart
GET /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.

Try itlive · a wrong reference is a real 404
GET /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
curl -L http://hub.flexibletechlab.com/api/orders/duns-1042/certificate \
  -H "x-api-key: $HUB_API_KEY" \
  -o certificate.pdf
POST /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.

StatusWhen
200Cancelled; returns the order.
404No order with that reference.
409It is already paid, filed, or closed.
Try itlive · only unpaid orders can go
POST /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.

multipart
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'
GET /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.

Try itlive · the response is large
GET /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.

200
{
  "ok": true,
  "destination": {
    "bankName": "OPay Digital Services Limited (OPay)",
    "accountNumber": "6471856354",
    "accountName": "Flexible Tech Lab"
  }
}
Try itlive

Behaviour

Order lifecycle

An order only moves forward, and only along the edges below. Anything else is refused with 409 rather than quietly accepted.

awaiting_settlement → active → in_progress → completed
StatusWhat it meansWho moves it
awaiting_settlementCreated, not paid. Nothing is filed.You, by transferring the settlement
activeMoney confirmed; with the filer.Hub (bank webhook or console)
in_progressBeing filed with the registrar.Hub
awaiting_customerThe registrar asked for something; requestNote says what.Hub, then you by resubmitting
completedDone; the certificate is available.Hub
rejectedRefused, with rejectReason. Refundable from the console.Hub
cancelledUnpaid and abandoned.You (before payment)
refundedSettlement 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.

EventFires when
order.createdThe order exists and is awaiting settlement.
order.settledThe transfer was confirmed; work starts.
order.in_progressFiled with the registrar (also after a resubmission).
order.awaiting_customerSomething is needed from the customer.
order.completedFinished; the certificate is available.
order.rejectedRefused, with a reason.
order.cancelledYou cancelled it before payment.
order.refundedThe 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.

verify a delivery (Node)
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.

POST /api/bank-webhook
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

CodeMeaning
200OK. On order creation it also means “already existed”: duplicate: true.
201Created.
400Bad input. errors[] names what to fix.
401Missing or wrong API key / bank secret.
403Tenant suspended, with the reason.
404Unknown product, order, or no certificate yet.
409State refuses the action (not on sale, already paid, wrong status).
429Rate limited. Wait retryAfter seconds.
500Our fault. Quote the X-Request-Id from the response.
Need a key, or stuck on an integration?

Onboarding is by hand. One WhatsApp message and we will set your tenant up.

WhatsApp +234 902 102 0979