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.
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.
-
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/registercurl -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. -
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.
| Credential | What it does | Lifetime |
|---|---|---|
| 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.
| Header | Required | Value |
|---|---|---|
X-API-Key | Yes | Your aggregator API key |
X-Timestamp | Yes | Current UTC time, ISO 8601, e.g. 2026-07-25T09:30:00Z |
X-Signature | Yes | HMAC-SHA256 over the request body, below |
Content-Type | Yes | application/json |
X-Trace-ID | no | UUID v4 for tracing. Omit it and we generate one, then return it. |
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()
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:
- Calls that are about you rather than a merchant — listing your merchants, setting your own webhook —
take no
tenant_id. - If you would rather post to the standard merchant endpoint
/api/ingestthan to/api/aggregator/submit, sign with your aggregator credential and addX-On-Behalf-Of: <tenant_id>. That header is required on that path — an aggregator credential cannot transact as itself, because it has no tax identity of its own.
What the passkey is, and is not
- It authorises one call: your registration. It is not a bearer secret.
- It is not accepted on any management endpoint. If you send one — in the body or as a header — the call
fails with
401 AUTHENTICATION_FAILEDand a message telling you to sign with your API key and secret instead. That is deliberate: a stale integration should fail loudly, not quietly. - Only its hash is ever stored. We cannot show it to you again, and neither can anyone else.
Three things that trip people up
- Clock drift. Your timestamp must be within five minutes of ours, or you get a
401. Keep the host on NTP. - Re-built bodies. Sign the exact bytes you send. Serialising the payload twice can reorder keys and change the hash, and the signature will not match.
- Wrong thing signed. The signature covers the raw body, not the headers or the URL. A
body-less
DELETEsigns the empty body.
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.
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.
{
"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": "…"
}
}
{
"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_idis server-minted and is the merchant's permanent id. Store it — every later call names the merchant with it.website,descriptionandindustryare optional but recommended — see Business profile below. They cost nothing to send and improve every classification we make for that merchant.demo_credentialsare 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_readytells you whether the merchant can submit. When it isfalse,missing_for_demonames 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_zoneat 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.
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.
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.
| Field | Behaviour |
|---|---|
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.
| Field | What 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.
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.
| Endpoint | Purpose |
|---|---|
/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 #
- 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. - 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.
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.
- Supply them whenever suits you. On
/api/aggregator/subtenantat creation, on/api/aggregator/subtenant/updatelater, or from the dashboard. One endpoint covers both environments — there is no separate call per environment. - If they are missing when a submission is asked for, the call returns a plain error saying
the credentials for that environment are not yet filled and validated, and names the endpoint that
stores them. Branch on the
reason_code, surface theactionto whoever operates that merchant. - Watch
demo_readyandmissing_for_demoon the create and list calls (§3) — that pair tells you which merchants in your estate still owe credentials, so you can chase them before a submission run rather than during one.
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.
| Call | Meaning | Errors 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 |
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:
- No submission — captured locally, IRN and QR minted, nothing sent to NRS. The default safe state.
- Test — submits to the NRS sandbox only, under the merchant's own test credentials. Never reaches live NRS.
- Production (if PROD is enabled for you) — submits to live NRS, and only when the
submit carries the explicit per-batch
prod_go: true.
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 #
- Set your own sink with
/api/aggregator/webhook; read the full subscribable event catalogue with/api/aggregator/webhook/events. - The authoritative NRS verdict arrives as
firs.feedback. Subscribe to it rather than polling. A status check (/api/aggregator/irn-status) exists as a fallback, not as the primary path. - Feedback on a submission you made on a merchant's behalf is delivered to both your sink and that merchant's own sink, if it has one. Expect to see the event twice across the two destinations, and key your handler on the invoice identity rather than on arrival order.
- A merchant can be given its own inbound endpoint via
/api/aggregator/subtenant/inbound, which mints or rotates it.
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.
{
"status": "error",
"error_code": "AUTHENTICATION_FAILED",
"message": "An invitation passkey is not accepted here. It is used ONCE, at registration, …",
"details": { }
}
| HTTP | error_code | What it means |
|---|---|---|
| 400 | VALIDATION_FAILED |
The body is malformed, or a required field is missing. details
names it |
| 401 | AUTHENTICATION_FAILED |
Bad signature, drifted timestamp, unknown key — or a passkey sent where credentials belong |
| 403 | AGGREGATOR_SCOPE_VIOLATION |
The named merchant is not yours |
| 404 | SUBTENANT_NOT_FOUND |
No such merchant under your account |
| 409 | DUPLICATE_MERCHANT_TIN |
Another of your merchants already carries that TIN |
| 409 | CANNOT_DELETE_CLEARED_SUBMISSION |
That submission cleared NRS, or is still in flight. It is not deletable |
| 422 | FIRS_CREDENTIAL_VALIDATION_FAILED |
NRS rejected the credential set. Nothing was stored |
| 429 | RATE_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 #
- Dedup is per merchant and
transaction_id. Always send a stabletransaction_id. Re-sending the same one returns the original outcome idempotently — a duplicate is a success echo, so adopt the returnedduplicate_of.irnrather than treating it as an error. - Resubmitting a non-cleared record just works. If an earlier attempt was rejected, failed,
held or never filed, fix the cause and re-send the same
transaction_id. It proceeds fresh and the stale attempt is superseded. You do not need to delete anything first. - Deliberate delete-and-free —
DELETE /api/aggregator/submission/{transaction_id}— frees an invoice number when you want it back. It deletes only a non-cleared record; a signed or in-flight record is protected with409 CANNOT_DELETE_CLEARED_SUBMISSION. A fiscalized record never disappears. Sign the empty body if you send none. - Unregistering a merchant wipes its data and frees its TIN. If you then re-register and re-send an invoice that was already fiscalized, your own store cannot know it was filed. NRS is the authority: the async verdict comes back as a duplicate carrying the existing IRN. Adopt it rather than assuming a clean slate.
10 · Aggregator best practices #
The habits that separate an integration that runs quietly from one that needs babysitting.
Credential handling and rotation
- Read your API secret from a secret store or environment variable at startup. Never commit it, never log it, never put it in a URL, a query string or a browser page you serve to your own users.
- Your aggregator credential is the most valuable secret you hold — it reaches every merchant you own. Give each merchant a scoped key (§4) instead of sharing yours; that way a leak at one merchant costs you one key, not the estate.
- Rotate scoped keys yourself, on a schedule and immediately on any suspicion, with
/api/aggregator/scoped-key/rotate. Revoke keys for merchants you no longer serve rather than leaving them live. - To rotate your own aggregator credential, contact support — we re-issue the pair. Plan for it: read the credential from config so a rotation is a config change, not a code change.
- Keep the webhook secret as carefully as the API secret. It is what proves an inbound call came from us.
- Your merchants' FIRS/NRS credentials are a different class of secret again — they belong to the merchant, not to you. Collect them over a channel the merchant trusts, never by email, and send them straight to us rather than storing a copy. We validate on receipt and vault them per environment; they are never echoed back to you or to the merchant. Point the merchant at How to retrieve your FIRS credentials and let them fetch their own.
Idempotency
- Derive
transaction_idfrom something stable in your own system — the invoice number, or your primary key — never from a timestamp or a random value. A retry must carry the same id, or you will create a second invoice. - Treat a duplicate as success. Store the returned IRN against your record and move on.
- Make your own handlers idempotent too: the same
firs.feedbackevent may arrive more than once, and on a merchant submission it arrives at two sinks.
Batching
- Send batches rather than looping single-record calls. One request with 100 invoices is easier on both sides than 100 requests, and the per-record response tells you exactly which ones landed.
- One batch, one merchant —
tenant_idscopes the whole call. Group your queue by merchant before sending. - Keep batches to a size you can re-drive comfortably. A few hundred records is a reasonable ceiling; very large payloads are rejected on size.
- Set your own
batch_idso a partial207is easy to reconcile against what you sent.
Retry and backoff
- Retry
429and5xxwith exponential backoff and jitter. Do not retry400,401,403,404or422— those need a fix, not another attempt. - A
401is almost always clock drift or a re-serialised body, not a bad key. Check those two before you rotate anything. - Re-sign on every retry. The timestamp is inside the signature, so a replayed request eventually falls out of the five-minute window.
- Cap your retries and park the record for a human rather than looping forever. Poll only as a fallback to webhooks, and never in a tight loop.
Webhook verification
- Verify the signature on every delivery with your webhook secret before you parse or act on the body. An unverified webhook is untrusted input.
- Compare signatures in constant time, and reject a delivery whose timestamp is outside your tolerance window.
- Answer
2xxquickly and do the work asynchronously. A slow endpoint looks like a failing one, and retries will pile up behind it. - Key your handler on the invoice identity in the payload, not on delivery order. Events can arrive out of order and more than once.
- Serve the endpoint over HTTPS. Log the
X-Webhook-Id— it is what support needs to trace a delivery.
TIN hygiene
- Never guess a TIN. Not a placeholder, not a padded value, not another company's. A wrong TIN is a wrong tax filing under a real taxpayer's identity, and it is not something we can quietly fix later.
- Get the TIN from the merchant in writing, and validate the shape before you send: an 8-4 TIN
(
NNNNNNNN-NNNN) or a 12–13 digit NRS Tax ID. - If a merchant cannot supply a TIN, leave the field empty and finish onboarding when it arrives. An empty field is honest; a guessed one is not.
- One TIN per merchant. If
409 DUPLICATE_MERCHANT_TINcomes back, find the existing merchant and update it — do not create a second record for the same company. - The same discipline applies to every coded field. Source HS and service codes from the published reference
lists, and send
unit_codeas a real code (§11). We hold what we cannot resolve rather than filing a guess, and you should do the same.
Reconciliation and status checking
- Keep your own ledger: your
transaction_id, the returned IRN, the last verdict, and when you last heard anything. That table is what makes reconciliation a query instead of an investigation. - Drive state from
firs.feedback. Use/api/aggregator/irn-statusto close the gap for records that have gone quiet, not as your main loop. - Reconcile on a cadence — daily is plenty for most estates. Look for records you submitted with no verdict, and verdicts you received for records you cannot find.
- Use the scheduled report (§11) as an independent check against your own totals per merchant per period.
- NRS is the authority. Where your store and the verdict disagree, the verdict wins. Adopt the IRN it names.
Sandbox first, then production
- Build entirely in Test. Every merchant starts in No-submission or Test, and both are structurally blocked from live filing — you cannot file by accident.
- Before asking for production, prove the full loop in Test for at least one merchant: create, validate,
submit, receive
firs.feedback, handle a rejection, handle a duplicate, and pull a PDF. - Production is enabled per aggregator by us, once. After that, each live batch still needs its own
prod_go: true— keep that flag explicit in your code and never default it to true. - Go live with one merchant and a small batch. Confirm the verdicts and your reconciliation before you ramp.
- Never point test fixtures at a production-mode merchant. Keep credentials for the two environments in separate configuration.
11 · Codes & reports #
- HS codes (goods) and service codes come from the published NRS reference lists. We validate against them; while the shipped list is a subset, an unknown code is a warning rather than a hard rejection. Source real codes from the reference endpoints — do not invent them.
- Unit of measure (
unit_code) on each line is a coded NRS invoice-quantity code, never free text. Leave it blank for theC62("each") default, which is right for most service, subscription and fee lines; or send a code; or send a word we map for you (EACH→C62,MONTH→MON,HOUR→HUR,DAY→DAY,YEAR→ANN,KG→KGM,BOX→XBX,BAG→XBG). A product word such asLICENSEis held, not filed. A real unit we cannot map is held too — we never guess. Full list: canonical schema → unit_code. - Reports:
/api/aggregator/reports/schedulereturns a time-windowed.xlsxof one merchant's or all merchants' submitted invoices, one row per line item, each invoice carrying a "View PDF" link. - Invoice PDFs are served per IRN from
pdf-generator.pronalytics.ng/i/{irn}.
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.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /api/aggregator/register |
passkey | Register, once, and receive credentials (§1) |
POST | /api/aggregator/subtenant |
HMAC | Create a merchant (§3) |
POST | /api/aggregator/subtenant/update |
HMAC | Merge merchant detail (§3) |
POST | /api/aggregator/subtenants |
HMAC | List your merchants (§3) |
POST | /api/aggregator/merchants |
HMAC | Merchant roster with readiness and counts |
POST | /api/aggregator/dashboard |
HMAC | Estate rollup — the figures the dashboard shows |
POST | /api/aggregator/subtenant/inbound |
HMAC | Mint or rotate a merchant's inbound endpoint (§7) |
POST | /api/aggregator/subtenant/suspend |
HMAC | Suspend or reactivate a merchant |
POST | /api/aggregator/subtenant/extend |
HMAC | Extend a demo merchant's expiry |
DELETE | /api/aggregator/subtenant/{tenant_id} |
HMAC | Unregister a merchant — wipes its data, frees its TIN (§9) |
POST | /api/aggregator/scoped-key |
HMAC | Mint a merchant-scoped key (§4) |
POST | /api/aggregator/scoped-keys |
HMAC | List scoped keys (§4) |
POST | /api/aggregator/scoped-key/rotate |
HMAC | Rotate a scoped key (§4) |
POST | /api/aggregator/scoped-key/revoke |
HMAC | Revoke a scoped key (§4) |
POST | /api/aggregator/validate |
HMAC | Validate only, file nothing (§5) |
POST | /api/aggregator/submit |
HMAC | Submit invoices for one merchant (§5) |
POST | /api/aggregator/transmit |
HMAC | Re-attempt delivery of an already-signed invoice (§5) |
POST | /api/aggregator/irn-status |
HMAC | Status fallback for a quiet record (§10) |
POST | /api/aggregator/merchant/status |
HMAC | Recent transactions for one merchant |
POST | /api/aggregator/payment/update |
HMAC | Update a merchant's payment status |
DELETE | /api/aggregator/submission/{transaction_id} |
HMAC | Delete a non-cleared submission (§9) |
POST | /api/aggregator/webhook |
HMAC | Set your own webhook sink (§7) |
POST | /api/aggregator/webhook/events |
HMAC | Read the subscribable event catalogue (§7) |
POST | /api/aggregator/reports/schedule |
HMAC | Download a submitted-invoices schedule (§11) |
13 · Checklist #
- Redeemed the invitation passkey; stored the API key, API secret and webhook secret from that one response.
- Signing every call with
X-API-Key/X-Timestamp/X-Signature; no passkey anywhere in the integration. - Registered each merchant with a real TIN and a postal code;
demo_readyis true, or you know which fields are outstanding. - Filled in
website,descriptionandindustryon each merchant, so its classifications fit its trade. - Know which merchants still owe FIRS credentials, and that a submission for one of them errors with the endpoint named — credentials are not needed to register, only to submit.
- Understood that once a merchant is mapped, has submissions on, and has validated credentials,
both
/ingestand/submitfile — so a history load into a live merchant submits. - Minted a scoped key for any merchant that calls the API itself.
- Running
/api/aggregator/validatein tests before/api/aggregator/submit. - Sending a stable
transaction_idon every record, and treating duplicates as success. - Verifying the signature on every webhook before acting on it.
- Subscribed to
firs.feedbackand driving state from it, not from the submit response. - Retry policy in place: backoff on
429and5xx, no retry on4xxthat needs a fix. - Reconciling on a cadence against your own ledger and the scheduled report.
- Understood the live-filing gate: production enabled for you, and per-batch
prod_go.