Kederi Docs
Playground

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.

In
NIP/GTB/ADEBAYO CHUKWU/rent for august
Out
Adebayo Chukwu
Rent intent: Rent NG confidence 0.78

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.

StatusCodeWhen
400invalid_requestMalformed body, missing descriptor, batch over 1000, or a field of the wrong type. The message names the offending field.
401missing_credentialNo API key and no signed-in session.
401invalid_api_keyUnknown or revoked key.
405method_not_allowedWrong HTTP verb for the path.
429rate_limitedPer-key per-minute limit exceeded. Carries Retry-After, in seconds.
429quota_exceededThe 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.
503auth_unavailableKey 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.

Two different numbers. The /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:

HeaderMeaning
RateLimit-LimitRequests allowed in a window, for this key
RateLimit-RemainingRequests left in the current window
RateLimit-ResetSeconds until the window ends (1 to 60, never 0)
RateLimit-PolicyThe policy behind the numbers, as N;w=60
Retry-AfterOn 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.

Scope. The version identifies the directory. A change to the parsing engine alters answers without touching a byte of directory data, and the API version speaks for that. There is no way to pin an answer to an older directory: the version is there to be observed, so you can tell when something moved rather than prevent it.

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.

ReasonWhat it means
already_correctthe engine already returns this; the report may predate a fix
cannot_verifyno public evidence that this entity is what the correction says
conflictsit contradicts an existing directory entry that other reports support
alias_too_genericthe alias would match unrelated narrations and cost precision
not_a_merchantthe descriptor names a person or a payment rail, not a merchant
out_of_scopea 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

FieldTypeDescription
descriptorrequiredstring max 500

Raw bank narration

amountnumber

Absolute amount. With date, this is what makes a series detectable: send the occurrences together in one batch and the answer carries recurrence. On a single-descriptor call it changes nothing, because one transaction cannot be a series.

datestring

ISO date (YYYY-MM-DD)

account_idstring 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.

directionstring 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: TRANSFER BETWEEN CUSTOMERS TO GTB <holder> - ACCESS - <sender> is money in and ...VIA <holder> TO <recipient> is money out, and telling them apart needs to know whose account it is, which the text never says. A supplied direction is truth and overrides the engine's reading of the text; the answer reports which happened in direction_source.

currencystring

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.

idstring 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 (rate_limited), or the account's monthly free tier is used up (quota_exceeded). Carries Retry-After in seconds, plus the RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset trio. Those three are on every key-authenticated response, not only this one, so a client can slow down before it is refused rather than after. The free tier is 5,000 enrichments per UTC month for the account, shared by all its keys; every billable answer carries kederi-quota-limit, kederi-quota-remaining and kederi-quota-reset, and a call that does not fit is refused whole and charged nothing.

Batch form: POST the same fields inside { "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

FieldTypeDescription
contentstring

The statement file's text, or a base64-encoded PDF. Send this or payload, never both

payloadobject

The object an open-banking API or aggregator returned, verbatim. Send this or content, never both

profilestring 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

mappingobject

Where the fields are, for a shape no profile covers. Needs transactions (the path to the array), fields.descriptor, fields.amount, fields.date, and a direction rule of kind indicator, signed or positive_is_debit

filenamestring

Only used to break a tie when the content does not settle the format

account_idstring max 64

A label for the rows of a statement file, which also gives them row ids. Nothing is stored against it

date_orderstring dmy · mdy · ymd

How to read an ambiguous date column. Send it when you know better than the file does

currencystring

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 (unreadable_statement, which carries the parser's own explanation)

401

Missing or invalid API key (when auth is enforced)

413

statement_too_large: over 5000 transactions, or over the byte limit. Send the statement in parts.

429

Rate limit exceeded (rate_limited), or the account's monthly free tier is used up (quota_exceeded); see POST /enrich

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.

ParameterTypeDescription
statusstring 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

FieldTypeDescription
descriptorrequiredstring max 500
expectedobject

The correct values, only the fields worth asserting

notestring 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

Open by design: no key required, so a prospective customer can check coverage before signing up.

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 (rate_limited), or the account's monthly free tier is used up (quota_exceeded); see POST /enrich

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

FieldTypeDescription
emailrequiredstring
passwordrequiredstring

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

FieldTypeDescription
emailrequiredstring
passwordrequiredstring
turnstilestring

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

FieldTypeDescription
emailrequiredstring
turnstilestring

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

FieldTypeDescription
tokenrequiredstring

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

FieldTypeDescription
emailrequiredstring

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

FieldTypeDescription
tokenrequiredstring
passwordrequiredstring

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

FieldTypeDescription
currentrequiredstring
nextrequiredstring

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

FieldTypeDescription
namerequiredstring

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

FieldTypeDescription
namerequiredstring

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

FieldTypeDescription
namerequiredstring

The key's current name

torequiredstring

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

FieldTypeDescription
namerequiredstring

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

FieldTypeDescription
namerequiredstring

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.

ParameterTypeDescription
fromstring

First day to include, as YYYY-MM-DD. With to, this is the window; without either, days back from today is used.

tostring

Last day to include, inclusive. Defaults to today.

daysinteger

How many days back from today, when no from is given. Ignored if from is.

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 billable unit is the enrichment, not the request: a batch of 1000 descriptors is one request and 1000 enrichments.

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".

FieldTypeDescription
typestring merchant · transfer · fee
namestring

Resolved merchant or counterparty name

categorystring

Consumer-friendly category (see taxonomy)

mccinteger | null

Mapped MCC when known

counterpartystring | null

Transfer counterparty

intentstring | null

Transfer intent (Rent, School fees, ...)

remarkstring | null

Free-text remark extracted from the narration

channelstring | null

How the money moved: Card / POS, Bank transfer, Mobile money, Bill payment, .... null when the narration does not say

processorstring | null

Detected processor/gateway (Square, Paystack, ...)

bankstring | null

Sending bank decoded from a NIP session id

initiated_atstring | 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.

countrystring | null

ISO country code inferred from markers or merchant home

currencystring | null

ISO 4217 code for the country this transaction resolved to, which is a property of the MERCHANT rather than of your amount. A card charge abroad resolves to the merchant's currency while the amount is still in the account's own, so treat this as a label and not as a conversion base.

locationstring | null
recurringstring | null

Known cadence for subscription merchants

referencestring | 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 (REF, RRR, TXN, RECEIPT, INVOICE, CONFIRMATION), and an M-PESA code. A bare number in a narration stays unnamed.

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.

creditboolean

True when the money moves toward the account

direction_sourcestring inferred · supplied

Where credit came from. supplied means you sent direction and it was taken as truth. inferred means it was read out of the narration, and it is the one field here that a correct engine can still get wrong: some narrations do not state which side of a transfer the account is on, and nothing recovers what is not written down. Send direction and this cannot happen.

lowboolean

True when the result is a low-confidence guess

feeobject | null

Fees only. Which charge this is and whether the customer could avoid it: statutory is set by government, service is an optional service they can switch off, transaction is charged per transfer.

confidencenumber
tracearray

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.

Reading 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.

FieldTypeDescription
steprequiredstring Raw · Normalize · Country · Direction · NIP session · Rule · Channel · Card purchase · Strip noise · Match · Fuzzy tail · Fallback

Which stage of the pipeline this was

detailrequiredstring

What that stage saw or concluded, in human-readable form

Found something wrong? The trace tells you which stage to name in a POST /feedback report, which is what turns one bad answer into a directory that no longer gets it wrong.
© 2026 Kederi. Proprietary. Early access. There is no uptime guarantee yet.