Pronalytics Aggregator Guide v2.7 · demo
Contents
  1. 1 · Onboarding — two steps
  2. 2 · Authentication — sign every call
  3. 3 · Register your merchants
  4. 3.1 · Business profile — website, description, industry
  5. 4 · Merchant-scoped keys
  6. 5 · Validate, then submit
  7. 5.1 · When FIRS credentials are required
  8. 5.2 · /ingest vs /submit
  9. 6 · Submission modes
  10. 7 · Webhooks & async verdicts
  11. 8 · Errors — one envelope
  12. 9 · Dedup, idempotency & resend
  13. 10 · Aggregator best practices
  14. 11 · Codes & reports
  15. 12 · Endpoint summary
  16. 13 · Checklist
  17. ← Full API reference
  18. How to retrieve your FIRS credentials

Aggregator Guide

For a software house, bureau, ERP vendor or group head office that fiscalizes invoices for many merchants under one account. This guide gives you the order to do things in and the behaviour to design around. For field-level request and response bodies, use the API reference; this guide does not duplicate the wire schema. Every endpoint is POST unless noted.

What changed in v2.7

Management calls are now authenticated with your aggregator API key and API secret, HMAC-signed, exactly like the merchant plane. The invitation passkey is used once, to register. If you built against an earlier copy of this guide that sent a passkey on every call, read §2 — that is the one change you need to make.

1 · Onboarding — two steps #

Onboarding an aggregator is two steps. Step 1 happens once. Step 2 is how you work from then on.

  1. Redeem your single-use invitation passkey

    Email [email protected]. We issue you a single-use invitation passkey — a one-time code that is tied to no one until you redeem it. Post it to /api/aggregator/register. This call is not signed; the passkey is what authorises it.

    POST /api/aggregator/register
    curl -X POST https://api.pronalytics.ng/api/aggregator/register \
      -H "Content-Type: application/json" \
      -d '{
            "passkey":       "<your single-use invitation passkey>",
            "name":          "Grollo Consulting",
            "contact_email": "[email protected]",
            "webhook_url":   "https://your-platform.example/pronalytics/webhook"
          }'

    The response carries your credentials, once:

    200 — shown once
    {
      "status":         "registered",
      "aggregator_id":  "agg-7c3d9f",
      "name":           "Grollo Consulting",
      "api_key":        "GROLLO-2026-7C3D9F12",
      "api_secret":     "…",
      "webhook_secret": "…",
      "webhook_url":    "https://your-platform.example/pronalytics/webhook",
      "production_allowed": false
    }

    Save the secrets before you close the response. Neither the API secret nor the webhook secret is retrievable afterwards. The passkey is consumed the moment registration succeeds and can never be reused; an invalid, expired or already-used passkey returns 403.

  2. Sign every call after that

    From here on, every aggregator call is authenticated with the API key and API secret from step 1, using the three signing headers (§2). The passkey has no further use — it does not authenticate anything and is not accepted on any management endpoint.

CredentialWhat it doesLifetime
Invitation passkey Authorises exactly one registration call Single use — consumed at registration
API key Identifies you on every request Ongoing
API secret Signs the requests you send us Ongoing — never sent on the wire
Webhook secret Signs the webhooks we send you, so you can verify them Ongoing

2 · Authentication — sign every call #

The aggregator plane uses the same signing scheme as the merchant plane — one scheme to implement, one to test. There is no login, token or OAuth handshake: you sign each request with your API secret, and the API key plus that signature are the whole of the authentication.

HeaderRequiredValue
X-API-KeyYes Your aggregator API key
X-TimestampYes Current UTC time, ISO 8601, e.g. 2026-07-25T09:30:00Z
X-SignatureYes HMAC-SHA256 over the request body, below
Content-TypeYes application/json
X-Trace-IDno UUID v4 for tracing. Omit it and we generate one, then return it.
Building the signature
timestamp = UTC ISO 8601                     # the same value you put in X-Timestamp
body_hash = SHA256(raw_request_body).hex()
message   = "{api_key}:{timestamp}:{body_hash}"
signature = HMAC_SHA256(api_secret, message).hex()
Python — sign an aggregator call
import hashlib, hmac, json, requests
from datetime import datetime, timezone

API_KEY    = "GROLLO-2026-7C3D9F12"
API_SECRET = "…"

def agg_post(path, payload):
    raw = json.dumps(payload).encode()          # sign the EXACT bytes you send
    ts  = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    sig = hmac.new(API_SECRET.encode(),
                   f"{API_KEY}:{ts}:{hashlib.sha256(raw).hexdigest()}".encode(),
                   hashlib.sha256).hexdigest()
    return requests.post(
        "https://api.pronalytics.ng" + path, data=raw,
        headers={"Content-Type": "application/json", "X-API-Key": API_KEY,
                 "X-Timestamp": ts, "X-Signature": sig})

agg_post("/api/aggregator/subtenants", {})

Naming the merchant

Your credential identifies you, not a merchant. Every call that acts on one merchant names it with a tenant_id field in the signed JSON body — the id we returned when you created it (§3). We resolve it, confirm you own it, and scope the call to that merchant alone.

Two exceptions worth knowing:

What the passkey is, and is not

Three things that trip people up

Which key signs what

Your aggregator credential authenticates the management plane. A merchant's own key, or a merchant-scoped key you minted (§4), never can — however correctly it is signed. That separation is structural, not a permission check, so a merchant key that leaks cannot reach your estate.

3 · Register your merchants #

A merchant is the seller whose invoices are fiscalized. You create one with /api/aggregator/subtenant. Only the company name is required, but supplying identity and credentials in the same call is what makes the merchant able to submit immediately.

POST/api/aggregator/subtenant HMAC

Create one merchant under your account. The response carries the merchant's own key, secret and webhook secret — you can hand those to the merchant, or keep submitting on its behalf with your own credential.

Request body
{
  "company_name": "Mollo Industries Ltd",
  "contact_email": "[email protected]",
  "phone": "08031234567",
  "tin": "12345678-0001",
  "address": {
    "street": "14 Marina Road", "city": "Lagos",
    "state_code": "LA", "lga_code": "LA-ETI",
    "postal_zone": "101241", "country": "NG"
  },
  "webhook_url": "https://your-platform.example/hooks/mollo",

  "website": "https://mollo.example",
  "description": "Manufactures and installs industrial cold-storage units.",
  "industry": "refrigeration equipment",

  "demo_credentials": {
    "service_id": "MOLLO01", "business_id": "…",
    "api_key": "…", "api_secret": "…",
    "public_key": "…", "certificate": "…"
  }
}
200 — secrets shown once
{
  "status": "created",
  "aggregator_id": "agg-7c3d9f",
  "tenant_id": "mollo-4f9a21",
  "name": "Mollo Industries Ltd",
  "api_key": "MOLLO-2026-4F9A21BC",
  "api_secret": "…",
  "webhook_secret": "…",
  "service_id": "MOLLO01",
  "expires_at": "2026-08-01T09:30:00Z",
  "demo_ready": true,
  "missing_for_demo": []
}
  • tenant_id is server-minted and is the merchant's permanent id. Store it — every later call names the merchant with it.
  • website, description and industry are optional but recommended — see Business profile below. They cost nothing to send and improve every classification we make for that merchant.
  • demo_credentials are optional. A merchant registers and is provisioned perfectly well without them; they are requested at the first submission attempt instead. Send them here only if you already hold them and want the merchant able to submit immediately. See When FIRS credentials are required (§5) and How to retrieve your FIRS credentials.
  • When you do send them, they are the merchant's own NRS Test credentials. We validate them against NRS before storing, write the secrets straight into the per-merchant vault, and never echo them back. If validation fails, nothing is stored — you never end up half-configured.
  • demo_ready tells you whether the merchant can submit. When it is false, missing_for_demo names exactly which fields are outstanding, so you can finish setup without guessing.
  • Postal code matters. A B2B invoice with no postal code is rejected by NRS, so fill postal_zone at registration rather than discovering it invoice by invoice.
  • One TIN per merchant. Registering a TIN that another of your merchants already carries returns 409 DUPLICATE_MERCHANT_TIN. Update the existing merchant instead of creating a second record for the same company.
POST/api/aggregator/subtenant/update HMAC

Fill in or correct merchant detail later. This is a merge, not a replace: send tenant_id plus only the fields you are changing. Everything you do not send is left alone.

POST/api/aggregator/subtenants HMAC

List every merchant you own, with readiness and expiry. Body is {}. No secrets are ever returned — key material is shown once, at the call that minted it, and masked everywhere after.

Identity fields — what we correct and what we reject

We correct a value only where the correction is unambiguous. Everything else is rejected with a {reason_code, message, action} triad rather than silently reshaped, so what you store round-trips exactly.

FieldBehaviour
phone Corrected to E.164 — a local 11-digit 0XXXXXXXXXX becomes +234XXXXXXXXX
service_id Upper-cased. It is a component of the IRN, so 1–12 alphanumeric characters only — anything longer or non-alphanumeric is rejected, never truncated
tin Validated, never altered. An 8-4 TIN (NNNNNNNN-NNNN) or a 12–13 digit NRS Tax ID. Anything else is rejected
postal_zone Validated. Required for a B2B party
email, rc_number Validated, never altered

Business profile — website, description, industry #

Three optional fields describe what the merchant actually does: website, description and industry. Nothing is blocked if you leave them out, but they are recommended on every merchant you create.

FieldWhat to send
website The merchant's public site, e.g. https://mollo.example
description One or two sentences on what the merchant sells or does
industry The sector it trades in, e.g. refrigeration equipment, civil engineering, pharmaceutical distribution

Why they matter to you specifically. Every compliant line needs a classification — an HS code for goods, a service code for services — resolved against a catalogue full of near-neighbours. "Installation" classifies one way for a lift company and another for a software vendor. These three fields seed a per-merchant profile that we consult on every code resolution and enrichment for that merchant, so the code we reach for fits that merchant's trade rather than a generic reading of the line text.

Across an estate this compounds. An aggregator with fifty merchants and no profiles gets fifty sets of generic classifications and a steady stream of flagged lines to chase; the same estate with profiles filled in gets classifications specific to each merchant's business and far fewer flags landing back on your desk. It is the cheapest quality win available at registration time.

Editable later. Send them on /api/aggregator/subtenant/update at any time — it is a merge, so tenant_id plus the field you are changing is enough. A merchant with its own dashboard login can edit them there. Changing one refreshes that merchant's profile automatically, so a merchant that moves into a new line of business gets classifications that move with it. No re-onboarding, nothing to re-upload.

What we never infer from a description

A TIN, a postcode, and a unit code are never derived from profile text — they come from what you send or from the merchant's registered details. Codes are chosen from the authoritative catalogue, never written freehand, and anything that cannot be resolved confidently is flagged rather than guessed.

4 · Merchant-scoped keys #

A scoped key is a credential that resolves to exactly one merchant. Mint one when you want that merchant to call the API directly without ever seeing your aggregator credential.

EndpointPurpose
/api/aggregator/scoped-key Mint a key scoped to one merchant. Key and secret are shown once
/api/aggregator/scoped-keys List scoped keys, optionally for one merchant. Secrets are masked
/api/aggregator/scoped-key/rotate Issue a new secret for the same scope. The old key stops working immediately
/api/aggregator/scoped-key/revoke Kill a scoped key. Immediate and final

Scoping is structural: a scoped key resolves to one merchant and nothing else, so a key minted for one merchant cannot be re-pointed at another — not even another of yours. A scoped-key holder signs with the same three headers and needs no tenant_id, because the key already names the merchant.

5 · Validate, then submit #

  1. Pre-flight — /api/aggregator/validate. Same field-level verdicts as a real submit, files nothing. Run your fixtures through it in your integration tests, and run a real payload through it the first time you touch a new merchant.
  2. Submit — /api/aggregator/submit (alias /api/aggregator/ingest). Body is {tenant_id, invoices: [...], batch_id?, prod_go?} — one invoice object or an array for bulk. The body is the canonical invoice model directly, with no adapter.

The response is synchronous and per-record: processed[], duplicates[], failed[] and a summary, with HTTP 200 when everything landed, 207 for a partial batch and 422 when every record was rejected. Each processed record carries its transaction_id, irn, qr_code and submission_status.

The sync response is not an NRS clearance

An IRN in the response means we accepted and signed the invoice. The authoritative NRS verdict arrives afterwards, on the firs.feedback webhook (§7). Build your state machine on that event, not on the submit response.

Every non-signed record additionally carries {reason_code, message, action} — a stable snake_case code, what happened, and what to do next. Branch your code on reason_code; show your users the message and action.

Re-transmitting. If an invoice already has an IRN and you need delivery attempted again — a counterparty joins the exchange later, for instance — use /api/aggregator/transmit with the IRN. It never re-mints; a fresh submit of the same invoice would come back as a duplicate.

When FIRS credentials are required #

Each merchant submits under its own FIRS/NRS credentials, never yours and never ours. But they are not required to register a merchant. You can create a merchant, wire it up and push its data with no FIRS credentials on file at all.

They are requested just-in-time — at the first submission attempt, and only for the environment being attempted. A demo submission asks for that merchant's demo credentials; a production submission asks for its production credentials. Either way they are validated against FIRS/NRS on the spot, and on failure nothing is stored.

What each merchant has to fetch from the FIRS/NRS portal — entity_id, business_id, service_id, api_key, api_secret, and the crypto pair (public key + certificate) — is set out step by step in How to retrieve your FIRS credentials. Send that page to a merchant rather than explaining it yourself. No private key is ever requested or stored.

/ingest vs /submit #

The two calls now mean different things, and the difference decides whether a missing credential is an error.

CallMeaningErrors when credentials are missing?
/submit "I want this invoice, or these invoices, submitted to FIRS." Yes — plain error, plus the endpoint that stores them
/ingest "I am not forcing submission; I just want the data visible on the dashboard." No — it never errors for this reason
The exception — when both calls submit

When all three of these are true for a merchant — its data is mapped, demo submissions are on, and its demo credentials are supplied and validated — then both /ingest and /submit submit to FIRS. Once a merchant is fully live for demo, ingestion implies fiscalization, and there is no longer a way to push a record into it and have it sit unsubmitted.

This matters at estate scale: a bulk backfill through /ingest against a fully live merchant files every record. If you are loading history for dashboard visibility only, load it before the merchant is live for that environment, or leave submissions off while you load.

Until a merchant reaches that state the split holds: /ingest stores and displays, /submit files. Note that /api/aggregator/ingest is an alias of /api/aggregator/submit and carries submit semantics; the distinction above is between asking for a submission and asking only for storage.

6 · Submission modes #

Each merchant sits in one of three modes:

Without prod_go, a production-mode record is captured and held and nothing is filed. That is the safety gate behaving correctly, not an error. An unparseable prod_go is a 400, never a silent downgrade to test. Test and No-submission are blocked from live filing server-side, at the last step before any NRS call.

7 · Webhooks & async verdicts #

Verify every delivery before you act on it — see §10. The full webhook envelope and per-event payloads are in the API reference, Appendix B.

8 · Errors — one envelope #

Every whole-request failure returns the same shape. Per-record outcomes inside a batch are reported in failed[] instead, with their own reason_code.

Error envelope
{
  "status":     "error",
  "error_code": "AUTHENTICATION_FAILED",
  "message":    "An invitation passkey is not accepted here. It is used ONCE, at registration, …",
  "details":    { }
}
HTTPerror_codeWhat it means
400VALIDATION_FAILED The body is malformed, or a required field is missing. details names it
401AUTHENTICATION_FAILED Bad signature, drifted timestamp, unknown key — or a passkey sent where credentials belong
403AGGREGATOR_SCOPE_VIOLATION The named merchant is not yours
404SUBTENANT_NOT_FOUND No such merchant under your account
409DUPLICATE_MERCHANT_TIN Another of your merchants already carries that TIN
409CANNOT_DELETE_CLEARED_SUBMISSION That submission cleared NRS, or is still in flight. It is not deletable
422FIRS_CREDENTIAL_VALIDATION_FAILED NRS rejected the credential set. Nothing was stored
429RATE_LIMIT_EXCEEDED Too many requests. Slow down; batch instead of looping single records

Branch on error_code, not on the message text — the codes are stable, the wording may improve. Quote the trace_id from a response when you contact support.

9 · Dedup, idempotency & resend #

10 · Aggregator best practices #

The habits that separate an integration that runs quietly from one that needs babysitting.

Credential handling and rotation

Idempotency

Batching

Retry and backoff

Webhook verification

TIN hygiene

Reconciliation and status checking

Sandbox first, then production

11 · Codes & reports #

12 · Endpoint summary #

Every endpoint below is authenticated with your aggregator API key and secret, HMAC-signed, except registration — which is authorised by your single-use invitation passkey.

MethodPathAuthPurpose
POST/api/aggregator/register passkeyRegister, once, and receive credentials (§1)
POST/api/aggregator/subtenant HMACCreate a merchant (§3)
POST/api/aggregator/subtenant/update HMACMerge merchant detail (§3)
POST/api/aggregator/subtenants HMACList your merchants (§3)
POST/api/aggregator/merchants HMACMerchant roster with readiness and counts
POST/api/aggregator/dashboard HMACEstate rollup — the figures the dashboard shows
POST/api/aggregator/subtenant/inbound HMACMint or rotate a merchant's inbound endpoint (§7)
POST/api/aggregator/subtenant/suspend HMACSuspend or reactivate a merchant
POST/api/aggregator/subtenant/extend HMACExtend a demo merchant's expiry
DELETE/api/aggregator/subtenant/{tenant_id} HMACUnregister a merchant — wipes its data, frees its TIN (§9)
POST/api/aggregator/scoped-key HMACMint a merchant-scoped key (§4)
POST/api/aggregator/scoped-keys HMACList scoped keys (§4)
POST/api/aggregator/scoped-key/rotate HMACRotate a scoped key (§4)
POST/api/aggregator/scoped-key/revoke HMACRevoke a scoped key (§4)
POST/api/aggregator/validate HMACValidate only, file nothing (§5)
POST/api/aggregator/submit HMACSubmit invoices for one merchant (§5)
POST/api/aggregator/transmit HMACRe-attempt delivery of an already-signed invoice (§5)
POST/api/aggregator/irn-status HMACStatus fallback for a quiet record (§10)
POST/api/aggregator/merchant/status HMACRecent transactions for one merchant
POST/api/aggregator/payment/update HMACUpdate a merchant's payment status
DELETE/api/aggregator/submission/{transaction_id} HMACDelete a non-cleared submission (§9)
POST/api/aggregator/webhook HMACSet your own webhook sink (§7)
POST/api/aggregator/webhook/events HMACRead the subscribable event catalogue (§7)
POST/api/aggregator/reports/schedule HMACDownload a submitted-invoices schedule (§11)

13 · Checklist #