Overview
Transaction enrichment: raw bank narration in, structured merchant/counterparty, category, intent, country, and recurrence out. Nigerian and African transfer narrations are the specialty.
Every JSON response carries x-kederi-directory, the version of the merchant directory that produced it. It is derived from the data files rather than maintained by hand, so it moves whenever they do. It identifies the DIRECTORY only: a change to the parsing engine alters answers without touching the data, and info.version above is what speaks for that. There is no way to pin an answer to an older directory, by design for now - the version is there to be observed.
NIP/GTB/ADEBAYO CHUKWU/rent for augustAdebayo Chukwu
Every request example on this page is generated from the OpenAPI spec, in whichever of 6 languages you pick above a code block, so an example cannot fall out of step with the API. The spec is the source of both, for 22 endpoints.
Every result carries a trace showing which stage decided it, and
low: true when the engine is guessing rather than resolving. A wrong
merchant name is worse than none, so the engine returns nothing rather than a
plausible-looking brand it cannot justify.
Coverage
662 merchants across 9 countries plus global brands; 53 categories. Nigeria is full coverage; Kenya, Ghana, South Africa, the UK, the US and the rest are beta. Live figures: GET /coverage.
Try it
Paste a narration and see what comes back.
This posts to the /enrich endpoint on this server - the same
one your code would call - so what you see here is what you get, and these
docs cannot drift from the API.
Cmd/Ctrl + Enter to run. The call needs a key, the same as your code: make one at /console, or sign in and leave the field empty. The key goes to this server and nowhere else. To try a narration with no account, use the playground.
Authentication
Pass a key as x-api-key, or as Authorization: Bearer.
Keys look like kdr_… and are stored only as SHA-256 hashes: the
plaintext is shown once, at creation, and cannot be recovered. What is kept
besides the hash is a short prefix, so a key is recognisable in a
list without being readable.
Every endpoint marked with a key needs a key or a signed-in session. A key that is supplied is always validated and rate-limited.
Creating a key
Sign up if you have not, open the link we send to confirm
your address, and make one: the keys panel creates, lists and revokes them,
and POST /keys does the same from code. The
response is the only time the key is readable.
Sign-up does not hand you a key, and it does not sign you in. It creates the account and sends a link; opening the link confirms the address and signs you in. The key is made in the console, the one place that also names, lists and revokes it.
An API key cannot create or revoke API keys, and that is deliberate rather than an omission. A key that could mint another one would survive its own revocation, so key management asks for the credential a person holds.
Each key carries the day it was last used, so you can tell when an old key is safe to turn off.
Two credentials, not one
An API key is what your software presents. A password is what a person presents to sign in to the console, and it opens a session held in an httpOnly cookie that page scripts cannot read. They are different credentials for different callers, and both resolve to the same owner, so your integration and your console see the same data.
Sign-in is POST /auth/login with an email and a password;
POST /auth/logout ends the session on the server rather than only in the
browser, and GET /auth/me reports which credential answered. There is no
way to trade a password for an API key or the reverse, on purpose.
What a key can see
A key belongs to your account, and it sees nothing of any other
account. It reads its own usage and the account's corrections; managing keys
needs a signed-in session. No transaction is stored, so a key cannot read one
back. account_id is a label you choose that groups
the transactions of one request, and it means nothing outside that request.
Every key you make in the console belongs to the same account, so a production key and a staging key share one usage total and one free allowance.
Batching
Send up to 1000 descriptors per request as
{ "transactions": [...] }. Results come back in
{ "results": [...] }, in the order supplied.
Batch, if you can. Billing counts enrichments, not requests, so a thousand in one call costs exactly what a thousand calls would, and one round trip instead of a thousand is faster for you and cheaper to serve.
Batching is also how recurrence is detected. Items
carrying amount and date are grouped by
account_id and compared within the request, so a
series sent together comes back with recurrence on each of
its occurrences. Nothing is stored, so a lone descriptor cannot be
matched against anything you sent earlier: send the occurrences you want
compared in the same call.
Errors & limits
Errors are always { "error": { "code", "message", "details?" } }.
The code is stable and safe to branch on; the message is
for humans and may change.
| Status | Code | When |
|---|---|---|
| 400 | invalid_request | Malformed body, missing descriptor, batch over 1000, or a field of the wrong type. The message names the offending field. |
| 401 | missing_credential | No API key and no signed-in session. |
| 401 | invalid_api_key | Unknown or revoked key. |
| 405 | method_not_allowed | Wrong HTTP verb for the path. |
| 429 | rate_limited | Per-key per-minute limit exceeded. Carries Retry-After, in seconds. |
| 429 | quota_exceeded | The call does not fit in what is left of the account's free allowance this month: 5,000 enrichments, and a separate allowance for a console session's reports. The call is refused whole and nothing is charged. Retry-After runs to the first day of next month. |
| 503 | auth_unavailable | Key store unreachable; the API fails closed rather than serving unauthenticated. |
Versioning
Every endpoint lives under /v1. A change that could break a
working integration becomes /v2, and /v1 keeps
answering exactly as it does today. Fields may be added to a response inside a
version; nothing is ever removed, renamed, or given a new meaning.
The unprefixed paths answer identically and will continue to.
POST /enrich and POST /v1/enrich are the same handler,
so nothing already written against this API needs changing. New integrations
should use the prefix: it is what makes the promise above possible.
/v1 in the path moves only when something breaks. The spec's
info.version moves on any change, including additive ones, and the
x-kederi-directory header moves whenever the merchant directory
does. None of the three is a substitute for the others.Rate limits
Fixed one-minute window, per key, set when the key is created (default 60/min).
Exceeding it returns 429 rate_limited with Retry-After in
seconds. Batching is the intended way to raise throughput without raising the
request rate.
You do not have to wait for the 429 to find out. Every response to a key-authenticated request carries the budget, so a client can slow down before it is refused:
| Header | Meaning |
|---|---|
RateLimit-Limit | Requests allowed in a window, for this key |
RateLimit-Remaining | Requests left in the current window |
RateLimit-Reset | Seconds until the window ends (1 to 60, never 0) |
RateLimit-Policy | The policy behind the numbers, as N;w=60 |
Retry-After | On a 429 only, in seconds. For rate_limited it equals RateLimit-Reset; for quota_exceeded it runs to the first day of next month |
Absent when no key authenticated the request, and absent if the usage store could not be reached: a budget nothing was counting is worse than no budget at all.
Which directory answered
Every JSON response carries an x-kederi-directory header, for
example 662.0dc1112b. It is derived from the directory
data rather than maintained by hand, so it moves whenever the data does and
cannot be forgotten.
It matters because the answer to the same descriptor can change.
A merchant we resolved badly six months ago is corrected the next time you send
it, and nothing tells you that happened unless you look. Compare
x-kederi-directory across calls and re-run what matters: you hold
the history rather than us, so re-running it is a request you can make whenever
you like.
Something wrong, or something missing?
A misresolved narration goes to POST /feedback, which is the loop that improves the directory. Anything else, including a limit you need raised, goes through the contact form, or write to hello@kederi.com.
And you get an answer.
GET /feedback lists everything you have reported with
a status of pending, applied or declined.
A declined correction says why, from a fixed set of reasons, because
"no" with no explanation just invites you to send it again.
| Reason | What it means |
|---|---|
already_correct | the engine already returns this; the report may predate a fix |
cannot_verify | no public evidence that this entity is what the correction says |
conflicts | it contradicts an existing directory entry that other reports support |
alias_too_generic | the alias would match unrelated narrations and cost precision |
not_a_merchant | the descriptor names a person or a payment rail, not a merchant |
out_of_scope | a market or a field the directory does not cover yet |
Enrichment
The core, and the only part that needs nothing else. Send a descriptor, get structured data back. No transaction is stored (a correction you send to /feedback is the one thing kept), so these are the endpoints most integrations use and never go past.
POST/enrichAPI key
Enrich one narration or a batch
Single form: an EnrichRequest object. Batch form: { transactions: EnrichRequest[] } with at most 1000 items. Nothing is stored. Items carrying amount + date are annotated with recurrence detected ACROSS THE BATCH, grouped by account_id, so send a series together if you want it detected.
Request body
Single form
| Field | Type | Description |
|---|---|---|
descriptorrequired | string max 500 | Raw bank narration |
amount | number | Absolute amount. With |
date | string | ISO date (YYYY-MM-DD) |
account_id | string max 64 | Groups transactions WITHIN one request, so a batch covering several accounts is not treated as one. It is a label, not a key: nothing is stored against it, and it means nothing outside the call that carried it. |
direction | string debit · credit | Money direction, from your statement's own debit/credit column. Send it. Some narrations do not state which side of a transfer the account is on: |
currency | string | ISO 4217 code for the amount, when you know it. Optional, and used only as a hint: the engine infers a country and currency from the narration itself. |
id | string max 64 | Your own id for this row, echoed back on the answer so a batch can be matched up without relying on order. It is not stored and does not deduplicate anything. |
Batch form
{ "transactions": [ … ] }: an array of the object above, at most 1000 per request. Results come back as { "results": [ … ] } in the same order.
Responses
| 200 | Enrichment result(s) |
| 400 | Invalid request |
| 401 | Missing or invalid API key (when auth is enforced) |
| 429 | Rate limit exceeded ( |
{ "transactions": [...] }, up to 1000 per request. The response is { "results": [...] } in the same order.POST/statementsAPI key
Read a bank statement file or an open-banking response, and enrich every row
Two doors, one answer. Send content (a statement file) or payload (the object an open-banking API or aggregator returned), and both come back as the same results a batch POST /enrich gives you.
Statement files: CSV, TSV and any other delimiter, JSON, OFX/QFX and QIF, and PDF for the two layouts calibrated against real statements (GTBank and Zenith). Send a PDF base64-encoded in content: raw bytes in a JSON string are corrupted before they arrive, and are refused with a message saying so. A PDF layout that is not one of the two is refused rather than guessed at, because these extractors read POSITIONS and the wrong one produces rows that look like rows.
A PDF answer adds statement.pdf, carrying the layout, the page count and a balance walk: each printed balance against the previous one plus the movement. A misread layout almost never reconciles, so check it before you keep the rows. GTBank statements print no running balance, and the report says so rather than reporting a reconciling zero.
Open-banking payloads: UK Open Banking (OBIE), Berlin Group NextGenPSD2, Plaid, Mono and Open Banking Nigeria, named by profile or detected from the shape. A standard not on that list needs no waiting: send a mapping describing where the fields are, and the same reader handles it.
Check statement.profile on your first call. It carries what the reading assumed, in particular whether amounts were treated as major or minor units and where the direction came from. Those two are the fields that can be wrong while every figure still looks like money.
The answer has two halves. statement is what the reading understood: the format, the delimiter, the header row, the column mapping, the date order and the parser's own complaints. Check it. The console shows a person that mapping and waits for them to confirm it; a server integration has no such moment, so it comes back in the response instead.
results are the same shape a batch POST /enrich returns, so anything that already handles one handles both.
Nothing is stored: not the file and not the rows. account_id stores nothing either.
Row ids: a statement file sent with account_id (not a PDF) gets row ids derived from row content, so the same file gives the same ids every time, and two identical rows within one file keep two different ids. Open-banking rows keep the provider's own ids.
A file whose dates could be day-first or month-first is READ, and the answer says so. statement.date_order carries the order used, whether it was certain, and why. Nothing is stored, so an ambiguous order cannot outlive the request that carried it. Send date_order when you know better than the file does.
Request body
| Field | Type | Description |
|---|---|---|
content | string | The statement file's text, or a base64-encoded PDF. Send this or payload, never both |
payload | object | The object an open-banking API or aggregator returned, verbatim. Send this or content, never both |
profile | string obie_uk · berlin_group · plaid · mono · openbanking_ng | Which shape the payload is. Omit it and the shape is detected; a payload matching more than one profile is refused rather than guessed, because the profiles disagree about sign and scale |
mapping | object | Where the fields are, for a shape no profile covers. Needs |
filename | string | Only used to break a tie when the content does not settle the format |
account_id | string max 64 | A label for the rows of a statement file, which also gives them row ids. Nothing is stored against it |
date_order | string dmy · mdy · ymd | How to read an ambiguous date column. Send it when you know better than the file does |
currency | string | Passed through to every row |
Responses
| 200 | What the file said, and every row enriched |
| 400 | Invalid request, or a file no transactions could be read from ( |
| 401 | Missing or invalid API key (when auth is enforced) |
| 413 |
|
| 429 | Rate limit exceeded ( |
GET/feedbackAPI key
What became of the corrections you reported
Your corrections, newest first, each with a status of pending, applied or declined. A declined one carries a reason from a closed vocabulary and, sometimes, a short human note.
Scoped to your ACCOUNT, never to the key that sent it: key names are unique per customer, so two customers can both have a key called production.
The response repeats the full reasons vocabulary, so a client can render any reason without hardcoding the list.
| Parameter | Type | Description |
|---|---|---|
status | string pending · applied · declined | Return only corrections in this state |
Responses
| 200 | { count, reasons, corrections: [...] } |
| 400 | A status that is not one of the three |
POST/feedbackAPI key
Report a misresolved narration
The corrections flywheel: tell Kederi what the right answer was. Feedback is queued, folded into the directory and the benchmark, and the outcome comes back on GET /feedback - including a reason when it is declined.
Request body
| Field | Type | Description |
|---|---|---|
descriptorrequired | string max 500 | |
expected | object | The correct values, only the fields worth asserting |
note | string max 500 |
Responses
| 200 | { ok: true, id } |
| 400 | Invalid request |
GET/coverageNo key
Supported countries and directory depth
Responses
| 200 | Country registry with per-country merchant counts |
Account intelligence
The reports that need more than one transaction: subscriptions, duplicates, the cash-flow forecast, counterparties, anomalies and the fee audit. You send the history and it comes back computed. Nothing is stored and nothing is read: there is no account to create, no id to come back to later, and no copy of your customers' transactions on our side. If you want a report over a year, send the year.
POST/reportsAPI key
Every analytic, over transactions you send, with nothing stored
Send transactions and get reports computed over exactly those rows. Nothing is stored. reports selects which reports to compute; omit it for all of them. amount and date are required on every transaction.
Send up to 5000 transactions. A larger batch is refused, not cut. Do not split a history across calls: a series across the split is not detected.
Transactions with more than one account_id are refused, except for subscriptions, which can combine the cards of one customer in the same currency. transactions returns the rows enriched again, with the directory as it is today.
Request body
Batch form
{ "transactions": [ … ] }: an array of the object above, at most 1000 per request. Results come back as { "results": [ … ] } in the same order.
Responses
| 200 | The requested reports, plus the count they were computed over |
| 400 | Invalid request: no transactions, a transaction with no amount or date, an unknown report name, or transactions spanning several accounts for a report that describes one |
| 401 | Missing or invalid API key (when auth is enforced) |
| 429 | Rate limit exceeded ( |
Your account and keys
Signing in, the keys your software presents, and what you have used. Plumbing rather than product: none of it enriches anything.
POST/auth/loginNo key
Sign a person in to the console
Exchanges an email and password for a session, returned as an httpOnly SameSite=Strict cookie. This is the console credential; software uses an API key instead, and the two cannot be traded for one another. A wrong password and an unknown address return the identical 401, because which of the two it was is not something a signed-out caller is entitled to know. Ten failures for one address in ten minutes returns 429.
Request body
| Field | Type | Description |
|---|---|---|
emailrequired | string | |
passwordrequired | string |
Responses
| 200 | { email, expires_at } and a Set-Cookie session |
| 401 | The email and password do not match an account |
| 403 | email_unverified: the password is right, and the address is not confirmed yet |
| 429 | Too many failed attempts for this address |
POST/auth/signupNo key
Sign up
Creates a user, gives it a NEW owner, and sends a link to the address. It does NOT sign you in: open the link, which confirms the address and signs you in. Until then the account cannot sign in. The link works once, for 24 hours; POST /auth/verify-again sends a new one.
It does NOT return an API key. A key comes from the one place that makes keys, and that is also the only place that can name, list and revoke them.
A new account never joins an existing owner. Sharing data between a person and an integration is something you ask for, not something a signup form does by guessing an address.
Where the host shows a captcha, turnstile is required, because sign-up is a form for a person.
Request body
| Field | Type | Description |
|---|---|---|
emailrequired | string | |
passwordrequired | string | |
turnstile | string |
Responses
| 201 | { email, owner_id, verification: "sent" | "not_sent", note }, and no session |
| 400 | Not an address, or a password under 12 characters |
| 409 | That address already has an account |
| 503 | Email is not set up on this host, so no account is made |
POST/auth/forgotNo key
Ask for a link to set a new password
Sends a link to the address if it has an account. The answer is the SAME 202 for every valid address, so it tells nobody which addresses have accounts. The link works once, for 30 minutes, and one account gets at most three an hour.
Where the host shows a captcha, turnstile is required: this is a form for a person, not a call for software.
Request body
| Field | Type | Description |
|---|---|---|
emailrequired | string | |
turnstile | string |
Responses
| 202 | { ok: true, note } whether or not the address has an account |
| 400 | Not an address, or no captcha token where one is required |
| 403 | The captcha did not pass |
| 429 | Too many requests from this address |
| 503 | Email or the captcha is not set up on this host |
POST/auth/verifyNo key
Confirm an address with the link from sign-up
Takes the token from the link (the part after #token=). The link is spent once. The address is confirmed, and the answer carries a session cookie, so you are signed in. A password reset by email also confirms the address.
Request body
| Field | Type | Description |
|---|---|---|
tokenrequired | string |
Responses
| 200 | { email, verified: true, expires_at, note } and a Set-Cookie session |
| 400 | The link does not work any more |
| 429 | Too many requests from this address |
POST/auth/verify-againNo key
Ask for a new link to confirm an address
Sends a new link if the address has an account that is not confirmed yet. The answer is the SAME 202 for every valid address. One account gets at most three links an hour.
Request body
| Field | Type | Description |
|---|---|---|
emailrequired | string |
Responses
| 202 | { ok: true, note } whether or not the address has an account that waits |
| 400 | Not an address |
| 429 | Too many requests from this address |
| 503 | Email is not set up on this host |
POST/auth/resetNo key
Set a new password with a reset link
Takes the token from the link (the part after #token=) and a new password. The link is spent once, and only when the password passes the rules. Every session of the account ends, and every other link of the account stops working. It also confirms the address, because the link reached its inbox. It does not sign you in: sign in with the new password.
Request body
| Field | Type | Description |
|---|---|---|
tokenrequired | string | |
passwordrequired | string |
Responses
| 200 | { ok: true, sessions_ended: true } |
| 400 | The link does not work any more, or the password breaks a rule |
| 429 | Too many requests from this address |
POST/auth/passwordSession
Change your own password
Needs the CURRENT password even though the caller is already signed in. A session is possession of a cookie, and this is the one action that would let whoever holds a stolen one keep the account.
Every OTHER session ends; the one making the request survives, or changing your password would sign you out of the page you did it on.
This is not a reset. Somebody LOCKED OUT uses POST /auth/forgot and POST /auth/reset.
Request body
| Field | Type | Description |
|---|---|---|
currentrequired | string | |
nextrequired | string |
Responses
| 200 | { ok: true, other_sessions_ended: true } |
| 400 | The new password breaks the length rule |
| 401 | That is not the current password |
| 403 | An API key has no password; this needs a session |
POST/auth/logoutSession
End the current console session
Deletes the session server-side and clears the cookie. Deleting the row is the part that matters: clearing a cookie only asks the browser to forget it.
Responses
| 200 | { ok: true } |
GET/auth/meAPI key
Which credential is calling, and whose data it sees
Reports via as key, session or dev, plus the owner. The console uses a 401 here as its signal to show the sign-in form.
Responses
| 200 | { via, email, key, owner_id } |
| 401 | No credential, and authentication is required |
GET/keysSession
List your API keys
Every key on your account: the name you gave it, its prefix, when it was created and the last DAY it authenticated a request. Never the key itself - only a SHA-256 of it is stored, so a lost key is replaced rather than recovered.
The last-used date is what makes an old key safe to turn off. Without it the honest answer to "can I revoke this one" is always no.
Responses
| 200 | { keys: [{ name, prefix, rate_limit_per_min, revoked, created_at, last_used_at }] } |
| 401 | Not signed in |
| 403 | An API key cannot manage API keys; this needs a session |
POST/keysSession
Create an API key
Returns the key ONCE. It is stored as a hash, so this response is the only time it exists in readable form.
The key belongs to the signed-in account, and there is no parameter for whose. An API key cannot call this: a leaked key that can mint a replacement is a leak revoking cannot close.
Request body
| Field | Type | Description |
|---|---|---|
namerequired | string | 2-40 characters of letters, digits, spaces, hyphens or underscores. Unique among YOUR keys. |
Responses
| 201 | { name, prefix, rate_limit_per_min, key, note } |
| 400 | The name breaks the character or length rule |
| 403 | An API key cannot create API keys; this needs a session |
| 409 | You already have a key with that name, or you are at the live-key limit |
POST/keys/revokeSession
Revoke one of your API keys
Immediate and one-way: the key stops authenticating on the next request and there is no un-revoke.
The row survives, marked revoked, because the usage and metering rows point at it. A bill that loses its line items when a key is turned off is a bill nobody can check.
Scoped to your account, so naming somebody else's key revokes nothing and reports nothing.
Request body
| Field | Type | Description |
|---|---|---|
namerequired | string |
Responses
| 200 | { name, revoked: true } |
| 403 | An API key cannot revoke API keys; this needs a session |
| 404 | No live key of yours has that name |
POST/keys/renameSession
Rename one of your API keys
The name is a label you chose, so you can change it. No new secret is issued and the key keeps working throughout.
Scoped to your account, so naming somebody else's key renames nothing and reports nothing. Names are unique per account among LIVE keys, so renaming onto a name one of your live keys already has is a 409.
Request body
| Field | Type | Description |
|---|---|---|
namerequired | string | The key's current name |
torequired | string | The new name |
Responses
| 200 | { name, renamed_from } |
| 403 | An API key cannot manage API keys; this needs a session |
| 404 | No live key of yours has that name |
| 409 | You already have a live key with that name |
POST/keys/rotateSession
Replace a key's secret, keeping its name
Issues a new secret under the same name and revokes the old one, as one transaction. The name in your config does not change, which is the reason to rotate rather than revoke and create.
There is no overlap window. The old secret stops authenticating the moment the new one exists, so deploy the new one promptly. If you need two live credentials during a changeover, create a second key instead and revoke the first once traffic has moved.
The replacement inherits the old key's rate limit rather than today's default.
As with creation, the secret is shown once and only its hash is stored.
Request body
| Field | Type | Description |
|---|---|---|
namerequired | string |
Responses
| 200 | { name, prefix, rate_limit_per_min, key, rotated: true } |
| 403 | An API key cannot manage API keys; this needs a session |
| 404 | No live key of yours has that name |
POST/keys/deleteSession
Delete a key that has never been used
Removes the row outright, for a key created by mistake.
A key that has been used cannot be deleted, and the refusal says so: its usage and metering rows point at it, and a bill that loses its line items is a bill nobody can check. Revoke that one instead. Revoking stops it working immediately and keeps the usage on your account, which is what you want for a key that did real work.
Scoped to your account, so naming somebody else's key deletes nothing.
Request body
| Field | Type | Description |
|---|---|---|
namerequired | string |
Responses
| 200 | { name, deleted: true } |
| 403 | An API key cannot manage API keys; this needs a session |
| 404 | No key of yours has that name |
| 409 | The key has usage; revoke it instead of deleting it |
GET/usageAPI key
Billable usage
Requests and enrichments per day. The billable unit is the enrichment, not the request: a batch of 1000 descriptors is one request and 1000 enrichments.
WHAT IT COVERS DEPENDS ON WHO ASKS. An API key sees itself and nothing else. A console session sees every key that account holds, broken out per key, because the person signing in owns them - the same rule that stops a key from listing the other keys.
Revoked keys are included: a key that was turned off last week still ran the calls it ran, and dropping it would stop the per-key rows adding up to the total.
Both ends of the window are days and both are inclusive. Usage is metered per day and nothing finer is recorded.
free_tier is the ACCOUNT's monthly allowance: 5,000 enrichments per UTC month, shared by every key the account holds and by the console's import. A call that does not fit is refused whole with a 429 quota_exceeded, and nothing is charged for it. Every billable answer carries the same figures in kederi-quota-limit, kederi-quota-remaining and kederi-quota-reset.
| Parameter | Type | Description |
|---|---|---|
from | string | First day to include, as YYYY-MM-DD. With |
to | string | Last day to include, inclusive. Defaults to today. |
days | integer | How many days back from today, when no |
Responses
| 200 | Totals, current-month totals, a per-key breakdown, the daily rows summed across it, and the account's free tier |
| 400 | The window is not two dates, or is the wrong way round |
The Enrichment object
Returned inside every /enrich result. Fields are always present;
those that do not apply are null rather than omitted, so a consumer
never has to distinguish "absent" from "unknown".
| Field | Type | Description |
|---|---|---|
type | string merchant · transfer · fee | |
name | string | Resolved merchant or counterparty name |
category | string | Consumer-friendly category (see taxonomy) |
mcc | integer | null | Mapped MCC when known |
counterparty | string | null | Transfer counterparty |
intent | string | null | Transfer intent (Rent, School fees, ...) |
remark | string | null | Free-text remark extracted from the narration |
channel | string | null | How the money moved: Card / POS, Bank transfer, Mobile money, Bill payment, .... null when the narration does not say |
processor | string | null | Detected processor/gateway (Square, Paystack, ...) |
bank | string | null | Sending bank decoded from a NIP session id |
initiated_at | string | null | Transaction time decoded from the same session id (YYYY-MM-DDTHH:MM:SS, the sender bank's local clock, not UTC). Null unless the id is exactly 30 digits and the embedded date and time are real. Statement feeds carry a value date and no time, so for NG transfers this is the only clock available. |
country | string | null | ISO country code inferred from markers or merchant home |
currency | string | null | ISO 4217 code for the country this transaction resolved to, which is a property of the MERCHANT rather than of your |
location | string | null | |
recurring | string | null | Known cadence for subscription merchants |
reference | string | null | The transaction reference the narration carries, and null far more often than not. It is answered only in the three shapes that SAY what they are: a NIP session id, an explicit marker ( That restriction is the feature. A bare number is often a phone number, a store number or a meter number, and a field that is confidently wrong is worse than one that is often null. |
credit | boolean | True when the money moves toward the account |
direction_source | string inferred · supplied | Where |
low | boolean | True when the result is a low-confidence guess |
fee | object | null | Fees only. Which charge this is and whether the customer could avoid it: |
confidence | number | |
trace | array | Every decision the engine made, in the order it made them. This is the whole pipeline rather than a summary: what the descriptor looked like raw, what normalising did to it, which rule or dictionary entry matched, what was stripped as noise, and what it fell back to when nothing matched. Returned on every result, so a misresolved narration can be diagnosed from the response instead of reported and waited on. |
low: when true the
engine could not resolve the narration and is offering a guess from its tokens.
Treat it as unresolved unless you have a reason not to.The trace
Every result carries the full list of decisions that produced it, in the order the engine made them. Not a confidence number and not a summary: the raw descriptor, what normalising did to it, which rule or dictionary entry matched, what was discarded as noise, and what it fell back to when nothing matched.
This is here so that a wrong answer is diagnosable from the response.
A resolver that returns a label and nothing else leaves you filing a ticket and
waiting; with a trace you can usually see the problem yourself, in the request you
already made. The Strip noise line is where to look first, because a
merchant that resolved wrongly almost always had the wrong text left to match
against.
| Field | Type | Description |
|---|---|---|
steprequired | string Raw · Normalize · Country · Direction · NIP session · Rule · Channel · Card purchase · Strip noise · Match · Fuzzy tail · Fallback | Which stage of the pipeline this was |
detailrequired | string | What that stage saw or concluded, in human-readable form |