{"openapi":"3.1.0","info":{"title":"Kederi API","version":"1.1.0","description":"Transaction enrichment: raw bank narration in, structured merchant/counterparty, category, intent, country, and recurrence out. Nigerian and African transfer narrations are the specialty.\n\n**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."},"servers":[{"url":"https://kederi.com/v1","description":"production"}],"components":{"securitySchemes":{"apiKey":{"type":"apiKey","in":"header","name":"x-api-key"},"bearer":{"type":"http","scheme":"bearer"},"session":{"type":"apiKey","in":"cookie","name":"kederi_session"}},"schemas":{"EnrichRequest":{"type":"object","required":["descriptor"],"properties":{"descriptor":{"type":"string","maxLength":500,"description":"Raw bank narration"},"amount":{"type":"number","description":"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."},"date":{"type":"string","description":"ISO date (YYYY-MM-DD)"},"account_id":{"type":"string","maxLength":64,"description":"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":{"type":"string","enum":["debit","credit"],"description":"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`."},"currency":{"type":"string","description":"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":{"type":"string","maxLength":64,"description":"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."}}},"Enrichment":{"type":"object","description":"The enrichment result for one descriptor.","properties":{"type":{"type":"string","enum":["merchant","transfer","fee"]},"name":{"type":"string","description":"Resolved merchant or counterparty name"},"category":{"type":"string","description":"Consumer-friendly category (see taxonomy)"},"mcc":{"type":["integer","null"],"description":"Mapped MCC when known"},"counterparty":{"type":["string","null"],"description":"Transfer counterparty"},"intent":{"type":["string","null"],"description":"Transfer intent (Rent, School fees, ...)"},"remark":{"type":["string","null"],"description":"Free-text remark extracted from the narration"},"channel":{"type":["string","null"],"description":"How the money moved: Card / POS, Bank transfer, Mobile money, Bill payment, .... null when the narration does not say"},"processor":{"type":["string","null"],"description":"Detected processor/gateway (Square, Paystack, ...)"},"bank":{"type":["string","null"],"description":"Sending bank decoded from a NIP session id"},"initiated_at":{"type":["string","null"],"description":"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":{"type":["string","null"],"description":"ISO country code inferred from markers or merchant home"},"currency":{"type":["string","null"],"description":"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."},"location":{"type":["string","null"]},"recurring":{"type":["string","null"],"description":"Known cadence for subscription merchants"},"reference":{"type":["string","null"],"description":"The transaction reference the narration carries, and null far more often than not.\n\nIt 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.\n\n**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":{"type":"boolean","description":"True when the money moves toward the account"},"direction_source":{"type":"string","enum":["inferred","supplied"],"description":"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."},"low":{"type":"boolean","description":"True when the result is a low-confidence guess"},"fee":{"type":["object","null"],"description":"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.","properties":{"type":{"type":"string","description":"Stable key, e.g. \"emtl\", \"sms_alert\""},"label":{"type":"string"},"avoidance":{"type":"string","enum":["statutory","service","transaction"]},"note":{"type":["string","null"]}}},"confidence":{"type":"number"},"trace":{"type":"array","description":"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.","items":{"type":"object","properties":{"step":{"type":"string","enum":["Raw","Normalize","Country","Direction","NIP session","Rule","Channel","Card purchase","Strip noise","Match","Fuzzy tail","Fallback"],"description":"Which stage of the pipeline this was"},"detail":{"type":"string","description":"What that stage saw or concluded, in human-readable form"}},"required":["step","detail"]}}}},"Error":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"details":{}}}}}}},"security":[{"apiKey":[]},{"bearer":[]}],"paths":{"/enrich":{"post":{"summary":"Enrich one narration or a batch","description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/EnrichRequest"},{"type":"object","required":["transactions"],"properties":{"transactions":{"type":"array","maxItems":1000,"items":{"$ref":"#/components/schemas/EnrichRequest"}}}}]},"example":{"descriptor":"NIP/GTB/ADEBAYO CHUKWU/rent for august","direction":"debit"}}}},"responses":{"200":{"description":"Enrichment result(s)"},"400":{"description":"Invalid request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key (when auth is enforced)"},"429":{"description":"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."}}}},"/statements":{"post":{"summary":"Read a bank statement file or an open-banking response, and enrich every row","description":"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.\n\n**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.\n\nA 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.\n\n**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.\n\n**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.\n\nThe 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.\n\n`results` are the same shape a batch `POST /enrich` returns, so anything that already handles one handles both.\n\n**Nothing is stored**: not the file and not the rows. `account_id` stores nothing either.\n\n**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.\n\n**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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"content":{"type":"string","description":"The statement file's text, or a base64-encoded PDF. Send this or payload, never both"},"payload":{"type":"object","description":"The object an open-banking API or aggregator returned, verbatim. Send this or content, never both"},"profile":{"type":"string","enum":["obie_uk","berlin_group","plaid","mono","openbanking_ng"],"description":"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":{"type":"object","description":"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"},"filename":{"type":"string","description":"Only used to break a tie when the content does not settle the format"},"account_id":{"type":"string","maxLength":64,"description":"A label for the rows of a statement file, which also gives them row ids. Nothing is stored against it"},"date_order":{"type":"string","enum":["dmy","mdy","ymd"],"description":"How to read an ambiguous date column. Send it when you know better than the file does"},"currency":{"type":"string","description":"Passed through to every row"}}},"example":{"content":"Date,Narration,Debit,Credit\n03/02/2026,NIP/ACCESS/OKONKWO CHIDI/RENT FOR FEBRUARY,250000.00,\n15/02/2026,SAL/FEB/BEACON ECOLE LIMITED,,480000.00","filename":"february.csv","account_id":"acct-1"}}}},"responses":{"200":{"description":"What the file said, and every row enriched"},"400":{"description":"Invalid request, or a file no transactions could be read from (`unreadable_statement`, which carries the parser's own explanation)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key (when auth is enforced)"},"413":{"description":"`statement_too_large`: over 5000 transactions, or over the byte limit. Send the statement in parts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (`rate_limited`), or the account's monthly free tier is used up (`quota_exceeded`); see `POST /enrich`"}}}},"/reports":{"post":{"summary":"Every analytic, over transactions you send, with nothing stored","description":"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.\n\nSend 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.\n\nTransactions 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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["transactions"],"properties":{"transactions":{"type":"array","description":"The transactions to report on. Same shape as a batch POST /enrich, except that amount and date are required","items":{"$ref":"#/components/schemas/EnrichRequest"}},"reports":{"type":"array","items":{"type":"string","enum":["summary","fees","subscriptions","duplicates","counterparties","anomalies","forecast","transactions"]},"description":"Which reports to compute. Omit for all of them"},"as_of":{"type":"string","description":"Treat this date as today. Changes which subscriptions read as due or lapsed, and where the forecast starts"},"days":{"type":"integer","description":"Forecast horizon in days (default 30)"},"category":{"type":"string","description":"Narrows the `transactions` report to one category. Applies to that report only, because it is the only one returning rows rather than a figure computed over them"},"name":{"type":"string","description":"Narrows the `transactions` report to one counterparty or merchant, matched on the canonical name, so \"Beacon Ecole Ltd\" also returns \"The Beacon Ecole Limited\""},"q":{"type":"string","description":"Narrows the `transactions` report by free text over the resolved name, the raw descriptor and the category"},"balance":{"type":"number","description":"Opening balance for the forecast. There is no balance in a statement feed, so without this there is no shortfall, only projected movement"}}},"example":{"transactions":[{"descriptor":"DSTV SUBSCRIPTION LAGOS","amount":24500,"date":"2026-06-05","direction":"debit"},{"descriptor":"DSTV SUBSCRIPTION LAGOS","amount":24500,"date":"2026-07-05","direction":"debit"},{"descriptor":"DSTV SUBSCRIPTION LAGOS","amount":24500,"date":"2026-08-05","direction":"debit"}],"reports":["subscriptions","summary"]}}}},"responses":{"200":{"description":"The requested reports, plus the count they were computed over"},"400":{"description":"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","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key (when auth is enforced)"},"429":{"description":"Rate limit exceeded (`rate_limited`), or the account's monthly free tier is used up (`quota_exceeded`); see `POST /enrich`"}}}},"/feedback":{"get":{"summary":"What became of the corrections you reported","description":"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.\n\nScoped 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`.\n\nThe response repeats the full `reasons` vocabulary, so a client can render any reason without hardcoding the list.","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["pending","applied","declined"]},"description":"Return only corrections in this state","example":"declined"}],"responses":{"200":{"description":"{ count, reasons, corrections: [...] }"},"400":{"description":"A status that is not one of the three"}}},"post":{"summary":"Report a misresolved narration","description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["descriptor"],"properties":{"descriptor":{"type":"string","maxLength":500},"expected":{"type":"object","description":"The correct values, only the fields worth asserting","properties":{"name":{"type":"string"},"category":{"type":"string"},"intent":{"type":"string"},"type":{"type":"string","enum":["merchant","transfer","fee"]}}},"note":{"type":"string","maxLength":500}}},"example":{"descriptor":"POS ADEYEMI STORES ILORIN","expected":{"name":"Adeyemi Stores","category":"Retail"},"note":"a local shop, not a person"}}}},"responses":{"200":{"description":"{ ok: true, id }"},"400":{"description":"Invalid request"}}}},"/coverage":{"get":{"summary":"Supported countries and directory depth","security":[],"responses":{"200":{"description":"Country registry with per-country merchant counts"}}}},"/auth/login":{"post":{"security":[],"summary":"Sign a person in to the console","description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"}}},"example":{"email":"you@example.com","password":"your-password"}}}},"responses":{"200":{"description":"{ email, expires_at } and a Set-Cookie session"},"401":{"description":"The email and password do not match an account"},"403":{"description":"email_unverified: the password is right, and the address is not confirmed yet"},"429":{"description":"Too many failed attempts for this address"}}}},"/auth/signup":{"post":{"summary":"Sign up","description":"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.\n\nIt 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.\n\nA 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.\n\nWhere the host shows a captcha, `turnstile` is required, because sign-up is a form for a person.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string","minLength":12},"turnstile":{"type":"string"}}},"example":{"email":"you@example.com","password":"a-long-enough-password"}}}},"responses":{"201":{"description":"{ email, owner_id, verification: \"sent\" | \"not_sent\", note }, and no session"},"400":{"description":"Not an address, or a password under 12 characters"},"409":{"description":"That address already has an account"},"503":{"description":"Email is not set up on this host, so no account is made"}}}},"/auth/forgot":{"post":{"security":[],"summary":"Ask for a link to set a new password","description":"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.\n\nWhere the host shows a captcha, `turnstile` is required: this is a form for a person, not a call for software.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string"},"turnstile":{"type":"string"}}},"example":{"email":"you@example.com"}}}},"responses":{"202":{"description":"{ ok: true, note } whether or not the address has an account"},"400":{"description":"Not an address, or no captcha token where one is required"},"403":{"description":"The captcha did not pass"},"429":{"description":"Too many requests from this address"},"503":{"description":"Email or the captcha is not set up on this host"}}}},"/auth/verify":{"post":{"security":[],"summary":"Confirm an address with the link from sign-up","description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string","pattern":"^[0-9a-f]{64}$"}}},"example":{"token":"0000000000000000000000000000000000000000000000000000000000000000"}}}},"responses":{"200":{"description":"{ email, verified: true, expires_at, note } and a Set-Cookie session"},"400":{"description":"The link does not work any more"},"429":{"description":"Too many requests from this address"}}}},"/auth/verify-again":{"post":{"security":[],"summary":"Ask for a new link to confirm an address","description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string"}}},"example":{"email":"you@example.com"}}}},"responses":{"202":{"description":"{ ok: true, note } whether or not the address has an account that waits"},"400":{"description":"Not an address"},"429":{"description":"Too many requests from this address"},"503":{"description":"Email is not set up on this host"}}}},"/auth/reset":{"post":{"security":[],"summary":"Set a new password with a reset link","description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token","password"],"properties":{"token":{"type":"string","pattern":"^[0-9a-f]{64}$"},"password":{"type":"string","minLength":12}}},"example":{"token":"0000000000000000000000000000000000000000000000000000000000000000","password":"a-long-enough-password"}}}},"responses":{"200":{"description":"{ ok: true, sessions_ended: true }"},"400":{"description":"The link does not work any more, or the password breaks a rule"},"429":{"description":"Too many requests from this address"}}}},"/auth/password":{"post":{"security":[{"session":[]}],"summary":"Change your own password","description":"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.\n\nEvery OTHER session ends; the one making the request survives, or changing your password would sign you out of the page you did it on.\n\nThis is not a reset. Somebody LOCKED OUT uses POST /auth/forgot and POST /auth/reset.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["current","next"],"properties":{"current":{"type":"string"},"next":{"type":"string","minLength":12}}},"example":{"current":"your-current-password","next":"a-long-enough-password"}}}},"responses":{"200":{"description":"{ ok: true, other_sessions_ended: true }"},"400":{"description":"The new password breaks the length rule"},"401":{"description":"That is not the current password"},"403":{"description":"An API key has no password; this needs a session"}}}},"/auth/logout":{"post":{"security":[{"session":[]}],"summary":"End the current console session","description":"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":{"description":"{ ok: true }"}}}},"/auth/me":{"get":{"summary":"Which credential is calling, and whose data it sees","description":"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":{"description":"{ via, email, key, owner_id }"},"401":{"description":"No credential, and authentication is required"}}}},"/keys":{"get":{"security":[{"session":[]}],"summary":"List your API keys","description":"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.\n\nThe 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":{"description":"{ keys: [{ name, prefix, rate_limit_per_min, revoked, created_at, last_used_at }] }"},"401":{"description":"Not signed in"},"403":{"description":"An API key cannot manage API keys; this needs a session"}}},"post":{"security":[{"session":[]}],"summary":"Create an API key","description":"Returns the key ONCE. It is stored as a hash, so this response is the only time it exists in readable form.\n\nThe 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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"2-40 characters of letters, digits, spaces, hyphens or underscores. Unique among YOUR keys."}}},"example":{"name":"production"}}}},"responses":{"201":{"description":"{ name, prefix, rate_limit_per_min, key, note }"},"400":{"description":"The name breaks the character or length rule"},"403":{"description":"An API key cannot create API keys; this needs a session"},"409":{"description":"You already have a key with that name, or you are at the live-key limit"}}}},"/keys/revoke":{"post":{"security":[{"session":[]}],"summary":"Revoke one of your API keys","description":"Immediate and one-way: the key stops authenticating on the next request and there is no un-revoke.\n\nThe 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.\n\nScoped to your account, so naming somebody else's key revokes nothing and reports nothing.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}},"example":{"name":"production"}}}},"responses":{"200":{"description":"{ name, revoked: true }"},"403":{"description":"An API key cannot revoke API keys; this needs a session"},"404":{"description":"No live key of yours has that name"}}}},"/keys/rename":{"post":{"security":[{"session":[]}],"summary":"Rename one of your API keys","description":"The name is a label you chose, so you can change it. No new secret is issued and the key keeps working throughout.\n\nScoped 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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","to"],"properties":{"name":{"type":"string","description":"The key's current name"},"to":{"type":"string","description":"The new name"}}},"example":{"name":"production","to":"production-eu"}}}},"responses":{"200":{"description":"{ name, renamed_from }"},"403":{"description":"An API key cannot manage API keys; this needs a session"},"404":{"description":"No live key of yours has that name"},"409":{"description":"You already have a live key with that name"}}}},"/keys/rotate":{"post":{"security":[{"session":[]}],"summary":"Replace a key's secret, keeping its name","description":"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.\n\n**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.\n\nThe replacement inherits the old key's rate limit rather than today's default.\n\nAs with creation, the secret is shown once and only its hash is stored.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}},"example":{"name":"production"}}}},"responses":{"200":{"description":"{ name, prefix, rate_limit_per_min, key, rotated: true }"},"403":{"description":"An API key cannot manage API keys; this needs a session"},"404":{"description":"No live key of yours has that name"}}}},"/keys/delete":{"post":{"security":[{"session":[]}],"summary":"Delete a key that has never been used","description":"Removes the row outright, for a key created by mistake.\n\n**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.\n\nScoped to your account, so naming somebody else's key deletes nothing.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}},"example":{"name":"typo-in-this-name"}}}},"responses":{"200":{"description":"{ name, deleted: true }"},"403":{"description":"An API key cannot manage API keys; this needs a session"},"404":{"description":"No key of yours has that name"},"409":{"description":"The key has usage; revoke it instead of deleting it"}}}},"/usage":{"get":{"summary":"Billable usage","description":"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.\n\nWHAT 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.\n\nRevoked 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.\n\nBoth ends of the window are days and both are inclusive. Usage is metered per day and nothing finer is recorded.\n\n`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`.","parameters":[{"name":"from","in":"query","schema":{"type":"string","format":"date"},"description":"First day to include, as YYYY-MM-DD. With `to`, this is the window; without either, `days` back from today is used.","example":"2026-08-01"},{"name":"to","in":"query","schema":{"type":"string","format":"date"},"description":"Last day to include, inclusive. Defaults to today.","example":"2026-08-31"},{"name":"days","in":"query","schema":{"type":"integer","default":30,"maximum":366},"description":"How many days back from today, when no `from` is given. Ignored if `from` is."}],"responses":{"200":{"description":"Totals, current-month totals, a per-key breakdown, the daily rows summed across it, and the account's free tier"},"400":{"description":"The window is not two dates, or is the wrong way round"}}}}}}