/template/e1e594b8931a41c5b171cf004ced597d
Nkwurite · For institutions evaluating the runtime
For an institution assessing Nkwurite. Three teams usually ask three different sets of questions, so this is organised the way the evaluation actually runs: engineering first, model and risk second, security and compliance third. Each section is something you execute yourself against your own sandbox tenant, not something we demonstrate to you. You need about an hour, an API key, and a terminal with curl, jq, openssl 3.x and xxd. Run the commands in order, in one directory: a later command reads the files an earlier one saves, and every file a command reads is written by a command before it.
The evaluation, in the order it runs
0 / What you were given
If you are reading this before a sandbox exists, ask for one: email partnerships@uuamni.com with the institution's name, the people who will run the evaluation, and whether you want the fraud and AML surfaces switched on. What comes back is the four items below.
| Item | Looks like | Where it goes |
|---|---|---|
| Tenant API key | nkw_… | Authorization: Bearer nkw_…, or X-API-Key: nkw_… |
| Base URL | https://api.<host> | everything below |
| Portal URL | https://portal.<host> | browser |
| Sign-in link | https://portal.<host>/tenant#signin=… | browser, once: it signs you in as yourself |
The sign-in link makes you the owner of your tenancy. From People in the portal you invite colleagues by email with a role (owner, admin, analyst, viewer); each of them gets their own one-time link and signs in as themselves. The API key is for your systems, and API Keys in the portal lets you make more, each with a label, a scope and an expiry.
The key identifies your tenant on every call. Treat it as a credential: not in client-side code, not in a repository, not in a URL query string.
Set it once:
export NKW_KEY=nkw_your_key_here
export NKW_API=https://api.<host>The Base URL is the scheme and host only: no trailing slash and no path. The …/v1 URL on the portal's Models page is only for the OpenAI SDK's base_url; do not use it as NKW_API.
Confirm you are who you think you are:
curl -s $NKW_API/api/portal/me -H "X-API-Key: $NKW_KEY" | jqThat returns your tenant record: name, type, status, allocation summary. If it returns 401, the key is wrong. If it returns a tenant that is not yours, stop and tell us immediately, because that would be a finding worth having.
1 / Engineering: does the API work
The gateway is mounted at /v1 with no prefix, so it is a drop-in base URL for the standard OpenAI SDKs. Your existing tooling works with two lines changed.
curl -s $NKW_API/v1/models -H "Authorization: Bearer $NKW_KEY" | jqTake the first model id from that list and send it a message:
export NKW_MODEL=$(curl -s $NKW_API/v1/models -H "Authorization: Bearer $NKW_KEY" | jq -r '.data[0].id')
echo "$NKW_MODEL"
jq -n --arg model "$NKW_MODEL" \
'{model: $model, messages: [{role: "user", content: "Say hello in one sentence."}]}' \
> chat.json
curl -s $NKW_API/v1/chat/completions \
-H "Authorization: Bearer $NKW_KEY" \
-H "Content-Type: application/json" \
-d @chat.json | jqSame call from the Python SDK (pip install openai), unchanged except base_url and api_key, reading the same three variables:
import os
from openai import OpenAI
client = OpenAI(base_url=os.environ["NKW_API"] + "/v1", api_key=os.environ["NKW_KEY"])
print(client.chat.completions.create(
model=os.environ["NKW_MODEL"], messages=[{"role": "user", "content": "hello"}]
).choices[0].message.content)Worth doing, because it is cheap and it is the kind of thing that is usually assumed rather than checked:
curl -s -o /dev/null -w "no key: %{http_code}\n" $NKW_API/v1/models
curl -s -o /dev/null -w "bad key: %{http_code}\n" \
$NKW_API/v1/models -H "Authorization: Bearer nkw_not_a_real_key"Both must be 401, in OpenAI error shape. Neither endpoint is ever anonymous.
curl -s $NKW_API/health | jq
curl -s -o /dev/null -w "ready: %{http_code}\n" $NKW_API/health/ready/health takes no key and answers with the overall status and nothing else: {"status": "healthy"}, "degraded" or "unhealthy", with 503 when a critical dependency is down and 200 otherwise. Your tenant key does not change that answer: the per-component detail is for our operators and needs an operator credential, and a tenant key sent as Authorization: Bearer is refused with 403 rather than ignored. /health/ready is the one to point a load balancer at: 200 when the environment can take traffic, 503 when it cannot. There is no /api/health: the health router is mounted at the root.
2 / Risk and model: are the scores usable
Everything in this section is also on screen: sign in to the portal with the same key and open Fraud Scoring and AML Monitoring in the sidebar. The screens call the endpoints below on your key, so what your risk analyst sees in the browser and what your engineer gets from curl is the same object.
A fraud score nobody can explain is not usable in a regulated process, so the evaluation here is less about the number and more about what travels with it.
Save one synthetic transaction to a file, then read it:
curl -s $NKW_API/api/portal/fraud/sample -H "X-API-Key: $NKW_KEY" > transaction.json
jq . transaction.jsonSynthetic, shaped to a Nigerian retail payment profile. The same seed returns the same transaction; add ?seed= with any other whole number for a different one. is_fraud is the generator's own label, there so you can compare it with the band the scorer gives. The scorer ignores it.
Post the file you just saved:
curl -s $NKW_API/api/portal/fraud/score \
-H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
-d @transaction.json | jqThe score call takes one transaction at a time. To score a different one, fetch it with another seed and post that file:
curl -s "$NKW_API/api/portal/fraud/sample?seed=7" -H "X-API-Key: $NKW_KEY" > transaction-7.json
curl -s $NKW_API/api/portal/fraud/score \
-H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
-d @transaction-7.json | jqEach result carries a risk score between 0 and 1 and reason codes: the specific contributing factors, so an analyst can say why a transaction was held and an examiner can follow the reasoning. This is the part to scrutinise. Ask whether the reasons your team would give match the reasons the scorer gives.
What the scorer is, precisely. An additive logit with every coefficient written down in a single table. That buys two things a tree ensemble does not: the attribution travelling with each score is exact rather than approximated, and there is no model artefact to version, explain or defend to an examiner. The contract is what we are asking you to evaluate here, and it is the part that does not change: a score, ranked reasons, and a policy band you can move.
The coefficients themselves are hand-set from known fraud patterns (balance drain, ticket anomaly, fresh beneficiary, new device, odd hour, velocity). They are a prior, not a fit. Nothing in this scorer is calibrated on real data, no training step has run, and no number it returns is a production detection rate. A trained scorer over your confirmed cases is what a pilot produces, and it arrives behind this same response shape, so an integration you build against the sandbox survives the swap.
curl -s $NKW_API/api/portal/fraud/thresholds -H "X-API-Key: $NKW_KEY" | jqApprove, step-up and hold bands against false-positive and catch rates, so the trade-off a threshold buys is visible and both lines are yours to move. The right threshold is a business decision about how much friction you will accept, not a model output.
Where those two rates come from, because it decides what they are worth to you. The endpoint generates a synthetic corpus, scores it with the hand-set coefficients described above, and grades the result against the generator's own labels. That generator writes the same patterns those coefficients weight, so what comes back is the agreement between two tables we wrote, not a measurement of detection on Nigerian retail traffic. Read it as the mechanism working end to end, and as the shape of the curve a threshold moves along. Do not carry the rates themselves into a threshold decision for your book. Those numbers come from a pilot, scored against fraud you confirmed.
The AML surface detects structuring and related typologies and produces an explainable alert: the typology matched, the contributing indicators, and a draft investigation narrative. Yours to run, on your own key, like the fraud surface above.
Save one case to a file, and look at the whole monitoring queue:
curl -s $NKW_API/api/portal/aml/sample -H "X-API-Key: $NKW_KEY" > case.json
jq . case.json
curl -s $NKW_API/api/portal/aml/feed -H "X-API-Key: $NKW_KEY" | jqtypology in the case is the generator's own label, there so you can compare it with what the detector matches. The detector ignores it.
Match the saved case to a typology, then draft the write-up an investigator would file:
curl -s $NKW_API/api/portal/aml/detect \
-H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
-d @case.json | jq
curl -s $NKW_API/api/portal/aml/narrate \
-H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
-d @case.json | jqThe narrative is the part worth reading with your MLRO beside you. It is drafted from the indicators that fired, not written freely, so what it says can be traced to what the detector saw. Same caveat as the scorer in 2.2: the corpus is synthetic and the typologies are hand-specified, so this is the contract and the explanation to assess, not a detection rate.
AML Monitoring in the portal sidebar is the same surface on screen.
A party is one customer or counterparty in your tenancy, filed under your own reference number. Cases, screening results and report records attach to a party, so this is where your AML work starts. Only synthetic parties are accepted in this environment: the API refuses anything else, and so does the database.
curl -s $NKW_API/api/portal/aml/parties \
-H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
-d '{"external_ref": "CUST-0001", "role": "CUSTOMER", "party_type": "INDIVIDUAL",
"full_name": "Adaeze Okonkwo", "country": "NG"}' | jqSearch your parties with GET /api/portal/aml/parties?q=.... Open one with GET /api/portal/aml/parties/{party_id}, and change its details or risk rating with PATCH on the same path. A rating other than UNRATED needs a reason.
What this gives you as evidence: every create and every change is a row in your own audit trail (3.2). The row records which fields changed, and the old and new rating when the rating moves. It never records the customer's name or identifiers, because the trail is append-only and cannot be edited afterwards. The party view also lists linked cases, screening hits and reports. A count reads null where the platform does not yet record that kind of item, so an empty list is never mistaken for a check that found nothing. The rating is your institution's; the platform prescribes no risk-rating method.
Parties in the portal sidebar is the same surface on screen.
A case is one investigation. It is opened from a monitoring alert, or by an investigator about a party already on file. It is assigned to a named person in your organisation, escalated with a reason if needed, and closed with a disposition (FALSE_POSITIVE, NOT_SUSPICIOUS or SUSPICIOUS) and a reason. A closed case cannot be edited: new facts open a new case.
Load the synthetic demo caseload (every alert in the generated corpus becomes a case, with its subject), or open one from an alert yourself. The alert route runs the detector on the activity you post and opens a case only if it alerts:
curl -s -X POST $NKW_API/api/portal/aml/cases/seed-demo \
-H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" -d '{"n": 50}' | jq
curl -s $NKW_API/api/portal/aml/cases/from-alert \
-H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" -d @case.json | jqWork a case: GET /api/portal/aml/cases lists your caseload (filter by state, owner_user_id, subject_party_id or overdue=true), and GET /api/portal/aml/cases/{case_id} opens one. Act on it with POST to /api/portal/aml/cases/{case_id}/assign, /api/portal/aml/cases/{case_id}/escalate, /api/portal/aml/cases/{case_id}/close, /api/portal/aml/cases/{case_id}/notes and /api/portal/aml/cases/{case_id}/links. POST /api/portal/aml/cases opens one by hand.
What this gives you as evidence:
GET /api/portal/aml/cases/metrics gives counts by state, source and disposition, the number of open cases past a deadline, median and mean turnaround from opening to closure, and the false-positive and suspicious shares of closed cases. You define and state how you calculate your own metrics; these are the raw figures.A case that is escalated, or closed as SUSPICIOUS, is where a suspicious-transaction report record is raised from (2.8).
Cases in the portal sidebar is the same surface on screen.
Every party is screened against the official sanctions lists loaded on this environment: when it is added, when a transaction names it as a counterparty, and whenever you ask. The lists are the UN Security Council Consolidated List, OFAC's SDN list, the UK Sanctions List, the EU Consolidated Financial Sanctions List and the Nigeria Sanctions List, each loaded from the publisher's own file. Each hit opens a case (2.6). It joins the case already open for that party and that listed target rather than opening another. A target you have already closed as a false positive is recorded on the next screen and not reopened.
See what screening compares against, and what it does not:
curl -s $NKW_API/api/portal/aml/screening/lists -H "X-API-Key: $NKW_KEY" | jqPOST /api/portal/aml/screening/parties/{party_id}/screen screens a party again now. GET /api/portal/aml/screening/parties/{party_id} returns every screening of it and every hit. To screen the counterparty on a transaction:
curl -s $NKW_API/api/portal/aml/screening/transactions \
-H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
-d '{"subject_party_id": "...", "transaction_ref": "TX-1",
"counterparty": {"external_ref": "CP-1", "full_name": "...", "party_type": "ORGANISATION"}
}' | jqWhat this gives you as evidence:
The threshold (0.85) is a starting point that favours catching a designated person over sparing an extra case. Setting it for your risk appetite is your institution's decision.
Parties in the portal shows each party's screening on its detail view.
A report record is what your institution drafts, reviews and keeps before it files. It is not a filing. Filing happens in the NFIU's goAML, and the platform does not produce goAML submissions (below).
POST /api/portal/aml/reports/from-case/{case_id} raises a suspicious-transaction report record from a case that is escalated or closed as SUSPICIOUS. Its first draft is the investigation narrative from 2.4, rebuilt from the transactions the case holds. A case with no transactions (a screening hit, a referral) gets a draft built from the case's own evidence, and the version says which. There is one STR per case.curl -s $NKW_API/api/portal/aml/reports/ctr \
-H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
-d '{"subject_party_id": "...", "period": "2026-09-28",
"transactions": [{"transaction_ref": "C1", "amount_ngn": 3000000, "cash": true},
{"transaction_ref": "C2", "amount_ngn": 2500000, "cash": true}]}' | jq
curl -s $NKW_API/api/portal/aml/reports -H "X-API-Key: $NKW_KEY" | jqGET /api/portal/aml/reports/{report_id} returns a report with every version of its text. POST /api/portal/aml/reports/{report_id}/revise edits it, and the edit becomes a new version with your reason; the earlier text is kept. POST /api/portal/aml/reports/{report_id}/ready marks it ready for your filer, after which it is not edited.
What this gives you as evidence: each report is linked to the case and the transactions it came from. Every version of its text is kept, with who changed it and why. Raising, each revision and the ready mark are rows in your audit trail, which records what happened to the report, never its text. GET /api/portal/aml/cases/metrics now also counts STR records and their ratio to closed cases. That is one raw input to the "STR conversion rate" an institution defines for itself. It counts records raised here, not filings.
Not built: goAML export.GET /api/portal/aml/reports/{report_id}/goaml answers 501 and says why. The goAML schema version is set on the NFIU's side and is not published in a form we can build against, and since 1 February 2026 goAML rejects improperly formatted submissions. It is unblocked by the schema version and XSD as shown in a reporting entity's own goAML account; with those, the exporter is built and validated against the XSD in the same change that turns it on.
Cases in the portal raises an STR from a case and shows its versions.
The thresholds detection uses are your institution's recorded decisions, not constants in our code. GET /api/portal/aml/models shows, for each detector (the AML typology detector and the fraud scorer), its code version, the parameters it uses for you now, and whether they are the platform default or a change you approved.
To change one, propose it with a reason:
curl -s $NKW_API/api/portal/aml/models/changes \
-H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
-d '{"model": "AML_TYPOLOGY", "parameters": {"alert_threshold": 0.6},
"reason": "Fewer weak mule alerts"}' | jqThe proposal is backtested at once: what the proposed parameters, and the current ones, would do to the synthetic corpus. The result is stored with the corpus seed and size and a SHA-256, so it can be re-run and compared. Someone other than the proposer then decides it: POST /api/portal/aml/models/changes/{entry_id}/decision with approve and a note. The same person or key cannot approve its own change. Approval supersedes the previous one, and from then on the AML alert threshold decides whether activity opens a case (2.6). The fraud bands decide a score's band whenever a request brings no policy of its own. A policy in the request is still honoured as a what-if. GET /api/portal/aml/models/changes lists every proposal with its decision, and GET /api/portal/aml/models/changes/{entry_id} returns one with its backtest.
What this gives you as evidence: every threshold change is on record with who proposed it, why, what it was expected to do, who decided and their note, and the detector version it was approved against. An approval does not carry to a new detector version: the default applies until you re-approve against the new one. Each step is a row in your audit trail.
What it is not: validation. The backtest runs on the platform's synthetic corpus, and it says so. That corpus is hand-specified, and every alert in it scores 1.00, so an AML threshold backtest shows no trade-off there. The figures describe the corpus, not your customers. How often you review a model, and who validates it, are your institution's governance.
Model Governance in the portal sidebar is the same surface on screen.
Sections 2.4 to 2.9 take the AML features one at a time. To see how they connect without building the data by hand, build the sandbox:
curl -s -X POST $NKW_API/api/portal/aml/sandbox -H "X-API-Key: $NKW_KEY" | jqOr press Build the AML sandbox on the Cases screen.
In your tenancy, on synthetic data, it:
seed-demo in 2.6);The response gives the ID of each record it made, so you can open them with the calls in 2.5 to 2.8 or on the Parties and Cases screens. Nothing is special about these records. They are made through the same service calls as the routes your investigators use, with the same validation, and each step is a row in your audit trail (3.2), attributed to your key. Calling the sandbox a second time returns the same walkthrough rather than a second one.
If your tenancy has no active person yet, the sandbox loads the caseload and stops, and says why: a case cannot be assigned to nobody. Add someone under People, then call it again.
What this gives you as evidence: one worked example of data flowing from an alert, to a case, to a report record, which is the "alert → case → regulatory reporting" flow the CBN's implementation roadmap template asks an institution to be able to show. It runs on synthetic data, so it shows the flow and the records, not detection on your customers.
The CBN AML Baseline Standards list fifteen headings under Section 5.1. The table below says, for each one, what you can do here and where. The fuller version, with what each feature gives your institution as evidence and what is not built, is section 8 of the control mapping (docs/compliance/cbn-2027-control-mapping.md).
Neither this guide nor the mapping makes any claim about Nkwurite's standing against the Standards or any CBN requirement, and neither should be read as one. The CBN does not recognise vendor claims of that kind. It assesses compliance within your institution's own implementation, and using a vendor does not transfer your accountability (CBN Guidance Note, 31 March 2026, §3 and §6). What the platform gives you is evidence to use when your institution makes its own case.
| Heading | What you can do here | Where |
|---|---|---|
| Customer Due Diligence (CDD), Know Your Customer (KYC) and Know Business (KYB) | Keep a party record per customer or counterparty. Identity is not verified | 2.5 |
| Sanction Lists | Screen parties and transaction counterparties against loaded UN, OFAC SDN, UK, EU and Nigeria list versions | 2.7 |
| PEP Screening | See PEP named as an absent source in every result, with its reason. No PEP data is loaded | 2.7 |
| Risk Assessment | See a party's rating, cases, hits and reports together. The rating is yours to set | 2.5 |
| Transaction Monitoring | Run the typology detectors on activity you post, and open a case from an alert | 2.4, 2.6 |
| Risk-Based Analyses | Set the detection threshold your institution uses, as a recorded decision | 2.9 |
| Case Management | Work a case from open to a disposition, with owner, reasons, notes and clocks | 2.6 |
| Regulatory Reporting | Raise STR records and generate CTR records. Export to goAML is not built | 2.8 |
| Management Information Reporting | Pull caseload, turnaround, outcome and STR figures | 2.6 |
| Audit and Governance | Pull your audit trail; propose, backtest and decide threshold changes | 3.2, 2.9 |
| System Integration & Scalability | Call the API from your systems. No connectors are built | 1, 2 |
| Security & Data Protection | Verify isolation, location, encryption and backup yourself | 3 |
| User Interface & Customisation | Use the Parties, Cases, Model Governance and AML Monitoring screens | 2 |
| Vendor/Third-Party Risk Management | Use this guide, the mapping and the evidence package in your own due diligence | 3.2.1 |
| Fraud Monitoring and Detection | Score transactions with reasons, and sweep thresholds | 2.1 to 2.3 |
3 / Security and compliance: the long pole
This is the section that decides evaluations, so it is the one with the most to check.
Application-layer checks protect against the code paths someone remembered to guard. Nkwurite additionally runs Postgres row-level security over the tables that hold each tenant's allocations, jobs, usage, billing, SSH keys, webhook deliveries, model deployments, AML parties and AML cases, with their links and notes, sanctions screening runs and hits, AML report records with their versions, and each institution's model-governance decisions. A query that arrives without a tenant context sees zero rows rather than all rows, and the policies apply to the table owner too.
The practical consequence: a service written next year that forgets to filter by tenant still cannot read across tenants, because the database refuses rather than the application remembering.
You cannot test another tenant's isolation from inside your own tenancy, by construction. Ask us to walk you through the isolation test in our suite, which creates two tenants and asserts the refusal, and note that a wrong-tenant resource id returns 404 rather than 403: an authorisation error that distinguishes "not yours" from "does not exist" is an enumeration oracle.
curl -s "$NKW_API/api/portal/audit" -H "X-API-Key: $NKW_KEY" | jqYours to pull, whenever you want, without asking us. The trail is immutable and covers actions you performed plus operator and system actions recorded against your account, including ours.
Score a transaction in section 2, then pull this: the call is there as an INFERENCE_CALL row whose resource_type is fraud_score, with the score and band it produced in detail. What you sent to be scored is not: the transaction is yours and the trail records the outcome, not your data.
Narrow it to a period. Encode the timestamp: the + in an ISO offset becomes a space when a URL is decoded, so paste the Z form or percent-encode the plus.
curl -s "$NKW_API/api/portal/audit?since=2026-08-01T00:00:00Z&limit=1000" \
-H "X-API-Key: $NKW_KEY" | jqRead scope_note on the response. It states what the export does and does not reach, and it travels with the data on purpose: an export that silently omits rows is worse than one with a stated boundary. Ask for the full trail for a formal audit and it will be produced.
The trail includes what we did to your resources, not only what we did to your account record: releasing one of your allocations is your row and appears here, attributed to uuamni-operator. Rows that belong to no tenancy, a platform configuration change, an operator signing in, are in no customer's trail. Rows written before 21 September 2026 carry the tenancy where it could be reconstructed from the row itself; ask and we will say which of yours those are.
What you will not see, deliberately: our operator context field, and the IP address on rows we performed rather than you. Those are internal, and the endpoint that exists to demonstrate isolation is a poor place to break it.
The paged trail above answers "show me my trail". When your auditor wants a file to keep, take the package:
curl -s "$NKW_API/api/portal/audit/package?since=2026-09-01T00:00:00Z" \
-H "X-API-Key: $NKW_KEY" -o nkwurite-audit-package.ndjsonOne JSON object per line, schema nkwurite.audit.package.v1. Line 1 is a manifest: what the package covers, how many rows matched, whether it was truncated, what it proves and what it does not. Then the environment's signed locality statement, then every audit checkpoint sealing the period you asked for, then your rows, oldest first.
Check the file is the one we issued, without calling us:
# The digest over everything after the manifest, and the digest the
# manifest claims. These must match.
tail -n +2 nkwurite-audit-package.ndjson | shasum -a 256
head -n 1 nkwurite-audit-package.ndjson | jq -r .body_sha256Then verify the signature over that digest. It is Ed25519 under the public key the manifest carries (raw, 32 bytes, hex), the same key that signs the checkpoints. The fixed prefix below is the standard DER header for an Ed25519 public key, which turns the raw key into one openssl reads:
head -n 1 nkwurite-audit-package.ndjson > manifest.json
jq -r .body_sha256 manifest.json | xxd -r -p > package.digest
jq -r .signature.value_base64 manifest.json | base64 -d > package.sig
{ printf '302a300506032b6570032100'; jq -r .signature.public_key manifest.json; } \
| xxd -r -p | openssl pkey -pubin -inform DER -out audit-pub.pem
openssl pkeyutl -verify -pubin -inkey audit-pub.pem \
-rawin -in package.digest -sigfile package.sigSignature Verified Successfully is the answer you want. The key's name is checkable too: signature.key_id is the first 16 hex characters of the SHA-256 of the raw public key.
jq -r .signature.public_key manifest.json | xxd -r -p | shasum -a 256 | cut -c1-16
jq -r .signature.key_id manifest.jsonWe keep our own implementation of both steps in the codebase, under test, so these instructions have something that fails when they drift.
Read does_not_prove before you rely on the file. Two entries matter most. The checkpoints seal the raw rows in our database and the entries in your package are the tenant-visible projection of them, so you cannot recompute a checkpoint hash from the package: the checkpoints are evidence about the trail, the signature is evidence about the file. And each checkpoint line states its row_schema. Seals under nkwurite.audit.row.v2 include the row's tenancy, so moving a row between two institutions' trails breaks the seal. Seals under nkwurite.audit.row.v1 were written before 22 September 2026, when tenancy was still outside the row digest; the file says so for those, rather than a footnote here.
Every inference call is metered per token and priced. Check that what you were charged matches what you called:
curl -s $NKW_API/api/portal/inference/usage -H "X-API-Key: $NKW_KEY" | jq
curl -s $NKW_API/api/portal/billing -H "X-API-Key: $NKW_KEY" | jq
curl -s $NKW_API/api/portal/billing/current -H "X-API-Key: $NKW_KEY" | jqThere is also a spend cap: a reservation is held for an in-flight request and released when it completes, so a runaway integration cannot silently run past a ceiling.
curl -s $NKW_API/api/portal/inference/spend-cap -H "X-API-Key: $NKW_KEY" | jqA stream you abandon mid-response is not billed. That is a stated policy, not an accident of implementation.
Ask us where this environment runs, and get the answer in writing.
From 1 January 2027, the CBN requires payments transaction data generated within Nigeria to be "stored and managed in Nigeria" (circular PSS/DIR/PUB/CIR/001/004, 15 June 2026), and NITDA's cloud guideline requires regulated financial institutions' financial data to be "primarily hosted, processed and stored" within Nigeria. Nkwurite is built so you never have to guess: our contracts keep Nigerian payment transaction data in Nigeria from 1 January 2027, de-identified or not, and each workload carries a signed statement of where it ran that you can verify in a browser.
So the question to put to any vendor, including us, is not "where is the data stored" but "where does inference execute". If the answer is a region name, ask which region, and ask what the accompanying control plane touches.
We will tell you exactly where the environment you are evaluating runs and what the production commitment is. If those two are different at the time you evaluate, we will say so plainly rather than let the evaluation imply otherwise.
The environment will tell you where it declares it runs, signed, and you do not have to take our word for the location. Ask it:
curl -s "$NKW_API/api/locality?nonce=$(openssl rand -hex 16)" -o response.json
jq . response.jsonNo API key. The nonce is yours: pick anything, and it comes back inside the signed bytes, so a response recorded from some other environment on some other day cannot be handed to you as an answer to this question.
You get back a declared site, facility, country, provider, region and zone; the public address the environment answers on and the organisation that address is registered to, with the date somebody checked; the host clock, its uptime (host.uptime_ms) and its time source with the clock offset (host.ntp_offset_us, whole microseconds); your nonce; and an Ed25519 signature over all of it.
Verify the signature, over the response.json you just saved:
jq -c -S '.attestation' response.json | tr -d '\n' > attestation.canonical
jq -r '.signature.value_base64' response.json | base64 -d > attestation.sig
jq -r '.signature.public_key_pem' response.json > locality-pub.pem
openssl pkeyutl -verify -pubin -inkey locality-pub.pem \
-rawin -in attestation.canonical -sigfile attestation.sigSignature Verified Successfully is the answer you want. (jq -c -S produces exactly the bytes we sign: the attestation object, keys sorted, no insignificant whitespace, UTF-8. Needs OpenSSL 3.x for -rawin. Works on jq 1.6 and 1.7: the signed object holds only strings, integers, booleans and nulls, because jq versions disagree on how to print a float, and attestation.version is an integer for the same reason. Keep the nonce to letters, digits, ., _, ~ and -, which is what openssl rand -hex gives you; anything else is refused with a 422 rather than signed into a document your tools would print differently from ours.)
Then check the provider's word for the zone (attestation version 3). On a cloud host the signed object carries provider_attestation: the provider's own identity document for the instance and the provider's RSA-2048 PKCS7 signature over it. That signature is the provider's, not ours, and you verify it against the provider's published certificate for the region, so the zone is attested by a third party rather than declared by us. For AWS:
# -j, not -r: the provider signed the exact bytes, and -r would append a newline.
jq -j '.attestation.provider_attestation.document_raw' response.json > identity.json
{ echo "-----BEGIN PKCS7-----"; jq -r '.attestation.provider_attestation.pkcs7_rsa2048' response.json | fold -w 64; echo "-----END PKCS7-----"; } > identity.p7
# The RSA-2048 certificate for the PARENT region (af-south-1 for the Lagos
# Local Zone) is AWS's, published in the Amazon EC2 User Guide under
# "Regions certificates" (https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/regions-certs.html).
# The API serves our vendored copy; a reader who distrusts us fetches AWS's
# copy and compares the two. Checked 2026-09-18 against the live Lagos
# environment.
curl -sS -o af-south-1-rsa2048.pem $NKW_API/api/locality/region-certificate
openssl smime -verify -in identity.p7 -inform PEM -content identity.json -certfile af-south-1-rsa2048.pem -noverify > /dev/null
jq '.availabilityZone, .instanceId, .accountId' identity.jsonVerification successful, and a zone equal to the declared one, is what you want:
jq -r '.attestation.declared.zone' response.jsonOn UUAMNI-owned hardware there is no provider document and the field is null; the facility's attestation and an address registered in Nigeria take its place, outside this document.
Then check the key is ours, not just self-consistent. A document that verifies against a public key printed inside the same document proves only that whoever wrote the document had a key. Compare signature.public_key_sha256 with the fingerprint we publish for that environment out of band, and check signature.key_is_persistent is true:
jq -r '.signature.key_id, .signature.public_key_sha256, .signature.key_is_persistent' response.jsonA false in that last line means the environment could not read its own signing key and minted a throwaway one, which verifies perfectly and means nothing.
Published signing-key fingerprints.signature.public_key_sha256 is SHA-256 over the raw 32-byte Ed25519 public key, hex. The values below were read from the key file on each host, not copied from an endpoint response, which is what makes this table independent of the thing it checks. If the fingerprint you receive is not in this table, either you reached an environment we did not publish, or the key was rotated and this table is stale: ask us before you rely on the answer either way.
| Environment | signature.key_id | signature.public_key_sha256 | Read from the host on |
|---|---|---|---|
| nk-lagos-eval-01 (AWS Lagos Local Zone) | nk-lagos-eval-01 | af6302b01d4fce1dbe635aeae531121cbb4b1b369cc964c45950a2b58d7438f8 | 2026-09-13 |
| nk-trial-01 (AWS Europe, Frankfurt) | nk-trial-01 | 3ba0f3ca0b7aeee98a922e4e38ab23281540d8b51f96bbad4037749db460690f | 2026-09-13 |
| Local development stack | development-ephemeral | not published on purpose: the key is minted at process start and key_is_persistent is false | n/a |
To reproduce a row, the operator reads the environment's signing key file on the host and runs it through openssl pkey -pubout -outform DER | tail -c 32 | sha256sum, with the key file on standard input.
What the signature proves: authorship (a holder of that environment's private key produced this), integrity (nothing changed after signing), and freshness (your nonce is inside the signed bytes).
What it does not prove: location. Every declared field is what the deployment told the host it is. A signature over a false claim is a signed false claim. That is why the endpoint publishes a does_not_prove list in its own response rather than leaving you to work it out, and why the checks below matter more than the signature does.
What it does not prove: that every operation on the host happens in Lagos. The Lagos evaluation host runs in the AWS Lagos Local Zone, af-south-1-los-1a. The instance and the traffic it serves are there. AWS handles some control-plane operations for a Local Zone instance, such as the API calls that start, stop and configure it, in the zone's parent Region, af-south-1, in Cape Town. The statement from a Local Zone host says so in its own does_not_prove list, naming the zone and the parent Region. It is a property of renting a Local Zone, and it is part of why UUAMNI-owned GPUs in a Lagos facility matter: on owned hardware there is no parent Region, and the statement from that environment carries no such entry.
Check the location independently. We cannot do these for you, which is the point. Take the host and the address from what you already have:
export NKW_HOST=${NKW_API#https://}
export NKW_ADDR=$(jq -r '.attestation.network.public_ip' response.json)
echo "$NKW_HOST $NKW_ADDR"
traceroute "$NKW_HOST"
whois "$NKW_ADDR"
dig -x "$NKW_ADDR" +shortWhat each one shows:
| Check | What you run | What it shows |
|---|---|---|
| Path and latency | traceroute "$NKW_HOST" from inside your own network | A Lagos-hosted endpoint reached from a Lagos bank shows single-digit to low-tens millisecond RTT and a path that stays on domestic infrastructure. A Frankfurt or Cape Town box cannot fake that from your side of the link |
| Independent probes | A RIPE Atlas ping or traceroute measurement from Nigerian probes to the address (atlas.ripe.net) | Third-party vantage points we do not operate, measuring the same address |
| Address registration | whois "$NKW_ADDR" | The RIR record: which organisation holds the address and which registry it sits in. Compare it to network.rir_org in the attestation. Expect this to be less local than the box: AWS's Lagos Local Zone addresses are ARIN-registered to Amazon.com, Inc. with a US organisation country (checked 2026-09-12 for 96.0.47.175), and the PTR record names the parent region, af-south-1. Neither contradicts a Lagos location; both are why the latency checks above carry more weight than the registry does. An AFRINIC record appears only on capacity we own |
| Reverse DNS | dig -x "$NKW_ADDR" +short | Cloud providers encode region in PTR records more often than they intend to |
Latency is the strongest of these and it is still evidence, not proof: a low RTT bounds how far away something can be, it does not tell you which building it is in. Taken together with a signed declaration that we are accountable for, it is what an honest answer to this question looks like before you have walked into the facility yourself.
What we measure for you, continuously. RIPE Atlas, a measurement network run by the RIPE NCC and not by us, pings the environment's public address every day and traceroutes it every week, from probes in Nigeria and from six anchors abroad (Cape Town, Johannesburg, Frankfurt, London, Virginia and Mumbai). The newest result is inside the statement you already have, under the same signature, and the last 30 days are one call away:
jq '.attestation.independent_evidence' response.json
curl -s "$NKW_API/api/locality/evidence" > evidence.json
jq '.measurements[] | {atlas_measurement_id, kind, measured_at, nigerian_probes_answered, min_rtt_nigeria_ms, nearest_foreign_anchor, verify}' evidence.jsonEach entry is one day of the ping, or one week of the traceroute, from a long-running Atlas measurement, and names that measurement's id and links to it. Open it on atlas.ripe.net and every probe, every packet and every number here can be recomputed from Atlas's own copy; we keep only the minimum round trip per probe. reading says what the numbers mean in plain words, with the limits in the same sentence: light in fibre covers about 100 km of distance per millisecond of round trip, so a round trip bounds how far the host can be from a probe, and a fast answer from Nigerian probes is evidence the host is not in any of the foreign anchor cities. It places the host to within tens of kilometres at best, never to a building, and it is only as good as the probes' own locations: about two per cent of Atlas probes are not where their operators say, which is why there are many and why the ids are published. An environment that has not been given an Atlas key says "status": "not configured" and why, rather than leaving the field out.
Why attest a cloud environment at all. The attestation is built for whatever environment answers it. Today that is a cloud evaluation environment in Lagos, and the provider's signed identity document is a third party's word for the zone. On UUAMNI-owned hardware in a Tier III Lagos facility, the same attestation ships with the facility's attestation and an address registered in Nigeria in place of the provider document. The response says this in its own scope field.
On environments that declare nothing. A box nobody configured answers with declared_profile_present: false and nulls, and says development. That is deliberate. We would rather an unconfigured environment tell you it does not know than inherit a plausible-looking site from a sibling profile and have you quote it.
The locality statement above signs where this environment runs. Each finished job also leaves its own signed record: your tenancy, the workload, the model version and weights digest it ran, when it started and ended, and the environment, site and country it ran in. Pull yours:
curl -s "$NKW_API/api/portal/attestations?limit=10" -H "X-API-Key: $NKW_KEY" > attestations.json
jq '{total, first: .attestations[0]}' attestations.jsonOne row per finished job, newest first. If total is 0, no job has finished on your tenancy yet; scoring calls in section 2 are recorded in your audit trail rather than here.
Each row carries signed_form, exactly the object the signature covers, so you rebuild the hash rather than guess at field order. Check the first row's hash, then its signature, with the key you already saved from the evidence package in 3.2.1 (the same key signs both):
jq -c -S '.attestations[0].signed_form' attestations.json | tr -d '\n' | shasum -a 256 | cut -c1-64
jq -r '.attestations[0].content_hash' attestations.json
jq -r '.attestations[0].content_hash' attestations.json | xxd -r -p > workload.digest
jq -r '.attestations[0].signature' attestations.json | base64 -d > workload.sig
openssl pkeyutl -verify -pubin -inkey audit-pub.pem \
-rawin -in workload.digest -sigfile workload.sig
jq -r '.attestations[0].key_id' attestations.json
jq -r .signature.key_id manifest.jsonThe two hashes must match, the signature must verify, and the two key ids must be the same. Read does_not_prove in the response too: site and country are this environment's declaration, the same one 3.4.1 signs and lets you check independently, and node and rack are null until they are recorded on UUAMNI-owned hardware.
Each row also carries log: where that record sits in the public log of every signed record (section 3.4.3), with the proof that it is there. A row signed in the last hour can read "log": null with a log_reason saying the next signed head will cover it.
Every inference request, a receipt. A job or a deployment gets the record above. A single call to the inference API gets its own: SHA-256 of the exact bytes you sent and of the exact bytes you received, the model and the weights version that answered, when, and where, signed the same way. Neither text is kept, by us or in the receipt. Its id comes back on the response:
jq -n --arg model "$NKW_MODEL" \
'{model: $model, messages: [{role: "user", content: "Receipt check, synthetic."}]}' > request.json
curl -s "$NKW_API/v1/chat/completions" \
-H "Authorization: Bearer $NKW_KEY" -H "Content-Type: application/json" \
--data-binary @request.json -D headers.txt -o completion.json
export NKW_RECEIPT=$(grep -i '^x-nkwurite-receipt-id:' headers.txt | tr -d '\r' | cut -d' ' -f2)
curl -s "$NKW_API/api/portal/receipts?limit=20" -H "X-API-Key: $NKW_KEY" \
| jq --arg id "$NKW_RECEIPT" '.receipts[] | select(.id == $id)' > receipt.json
shasum -a 256 request.json | cut -c1-64
jq -r '.request_sha256' receipt.json
shasum -a 256 completion.json | cut -c1-64
jq -r '.response_sha256' receipt.jsonEach pair must match. --data-binary, not -d: -d strips newlines and you would be hashing bytes you did not send. Check the receipt's signature exactly as the workload record's above, with .signed_form, .content_hash and .signature; the same key signs both. GET /api/portal/receipts lists yours, newest first, and GET /api/portal/receipts/{receipt_id} returns one by the id in the header. A receipt is issued for every request a model answered, including a model's own error passed back to you and a stream that broke or that you left early (its outcome says which); a request refused before any model was asked gets none, because nothing was served.
A signature tells you who wrote a record and that it has not changed. It cannot tell you that we showed everyone the same record, or that we never dropped one. The environment keeps a public, append-only log of every signed record (each workload record, each receipt, each seal of the audit trail, and one locality statement a day), the structure Certificate Transparency uses (RFC 6962). Its head, how many records and the hash over all of them, is signed with the locality key at least once a day. Anyone can ask for the proof that a record is in it, and the proof that a later log contains an earlier one unchanged. No key is needed.
From the receipt you saved in 3.4.2, to its proof, to the signed head:
export CONTENT_HASH=$(jq -r '.content_hash' receipt.json)curl -s "$NKW_API/api/transparency/proof?leaf=$CONTENT_HASH" > proof.json
h() { xxd -r -p | shasum -a 256 | cut -c1-64; }
# The leaf hash is SHA-256 over 0x00 followed by the record's content hash.
r=$( { printf '00'; jq -r .content_hash proof.json; } | h )
[ "$r" = "$(jq -r .leaf_hash proof.json)" ] && echo "leaf hash OK"
# Walk the audit path to the root (RFC 9162, section 2.1.3.2).
fn=$(jq .leaf_index proof.json)
sn=$(( $(jq .tree_size proof.json) - 1 ))
for p in $(jq -r '.audit_path[]' proof.json); do
if [ $((fn & 1)) -eq 1 ] || [ "$fn" -eq "$sn" ]; then
r=$(printf '01%s%s' "$p" "$r" | h)
while [ $((fn & 1)) -eq 0 ] && [ "$fn" -ne 0 ]; do fn=$((fn >> 1)); sn=$((sn >> 1)); done
else
r=$(printf '01%s%s' "$r" "$p" | h)
fi
fn=$((fn >> 1)); sn=$((sn >> 1))
done
[ "$sn" -eq 0 ] && [ "$r" = "$(jq -r .tree_head.signed.root_hash proof.json)" ] && echo "inclusion OK"
# The head's signature, over the canonical bytes of what it signs.
printf %s "$(jq -c -S .tree_head.signed proof.json)" > head.bytes
jq -r .tree_head.signature.value_base64 proof.json | base64 -d > head.sig
openssl pkeyutl -verify -pubin -inkey locality-pub.pem -rawin -in head.bytes -sigfile head.sigThree lines must print: leaf hash OK, inclusion OK, and Signature Verified Successfully, the last against the same locality key you checked against the published fingerprint in 3.4.1. A receipt from the last hour may answer "included": false with a reason: the next head covers it. This needs an OpenSSL 3 build (-rawin); macOS ships LibreSSL as /usr/bin/openssl, so use the Homebrew one there.
That the log only grows. Keep a head today, ask again next week, and ask for the proof that the newer log contains the older one:
curl -s "$NKW_API/api/transparency/tree-heads" > heads.json
jq '.tree_heads | map(.signed | {tree_size, timestamp})' heads.jsonGET /api/transparency/consistency?from=OLD&to=NEW returns that proof, with OLD and NEW two tree sizes from that list. Checking it by hand is the RFC 9162 section 2.1.4.2 algorithm; we publish a 200-line standalone checker that does it, and the inclusion check above, with nothing of ours imported. Ask for it if you want it rather than writing your own.
The external witness. A log we run is still a log we run. Where the environment has it switched on, each head's SHA-256, and nothing else, is also recorded in the public Sigstore Rekor log, which the Sigstore project runs, outside UUAMNI. The head's witness says so and links the entry:
curl -s "$NKW_API/api/transparency/tree-head" > head.json
jq '.tree_head.witness' head.json
printf %s "$(jq -c -S .tree_head.signed head.json)" | shasum -a 256 | cut -c1-64That hash is what Rekor holds. What Rekor gives you is its own timestamp for the head and a public record of it we cannot quietly change: if we ever showed two readers two different heads for the same size, both would be in Rekor. What it does not give you: Rekor does not know what any record says, and vouches for none of our declarations. An environment without the witness says "no external witness configured" rather than leaving the field out. The Rekor entry is signed by a witness key derived from the locality key, because Rekor's hashed entries do not accept the locality key's signature scheme; GET /api/transparency/witness-key returns the locality key's signed statement naming it.
| Question | Answer |
|---|---|
| Key rotation | Supported and audited; rotation is recorded in your trail |
| Key in transport | TLS 1.2 floor, 1.0 and 1.1 removed, HSTS on |
| Credentials at rest | API keys stored hashed, never in plaintext |
| Backups | Daily dumps with checksum manifests, weekly and monthly retention tiers, offsite push configurable |
| Recovery | A written recovery procedure per loss scenario, from a restore to a full rebuild. Rehearsal position in 3.6 |
| Sub-processors | Named on request; the current list depends on where your environment runs |
| Incident response | A written procedure per failure class, linked from each alert rule. The classes are listed in 3.6 |
| Right to audit | Contractual, and the trail in 3.2 is the standing evidence |
Every alert our monitoring raises resolves to a written procedure: what is broken for whom, how to triage it, how to recover, and when to escalate. The procedures themselves are internal, because they carry host names, contact numbers and the order in which we take our own systems apart. What they cover is not, and it is the part you are entitled to assess:
| Failure class | What the procedures cover |
|---|---|
| API availability | The API is down, returning errors, slow at the 95th percentile, or shedding load at the rate limiter. |
| GPU health | A GPU is over temperature, the fleet is saturated, a card is in maintenance, or the telemetry exporter that watches them has stopped. |
| Compute node health | A compute node is unreachable, has stopped reporting, or is running out of disk before it takes work with it. |
| Scheduling | Jobs are queuing without starting, or failing at a rate that is not the tenant's fault. |
| Metering and billing | Usage collection has stalled, a worker loop has stopped, webhook deliveries are backing up, or billed-but-unpaid balances have grown past their threshold. Metering failures are treated as incidents because unmetered GPU time never back-fills. |
| Model serving | The OpenAI-compatible gateway is failing, a model deployment is stuck loading, or a model server needs bringing up or tearing down. |
| Power and facility | Facility power events and the fleet's power commitment. |
| Data protection and recovery | Backup, restore, recovery from partial or total loss, and reconciling database schema drift before it reaches data. |
| Audit trail integrity | The tamper-evidence chain, its retention sweep, and the export a security information and event management system consumes. |
| Access and credentials | Certificate renewal, secret and role rotation, operator sign-on, and the hardening checklist run before anyone outside UUAMNI is let in. |
| Tenant lifecycle | Onboarding a tenant end to end, and moving one between tiers or hardware without losing its billing continuity. |
| Capacity and platform changes | Adding capacity to a live cluster, standing up an environment, and upgrading the scheduler underneath running work. |
| Sanctions screening data | An official sanctions list has not been refreshed, a new version failed to load, or a load needs checking against the publisher's file before anyone relies on it. |
Each alert rule carries a link to its own procedure, so the person paged does not go looking. Ask and we will walk you through any class on this list, or share a redacted procedure under our mutual NDA. The disaster recovery procedure is the one most institutions ask for first.
Where rehearsal stands, plainly. The recovery procedure sets a quarterly drill cadence, alternating a database restore with a full rebuild, and the backup and restore tooling is exercised by our test suite on every change. One environment drill is on record, dated 29 September 2026: the evaluation environment's own encrypted backup was pushed to a second host in Nigeria, restored there, and compared with the source table by table. The record says what was restored, how long it took, and the seven things that did not work the first time; we send it on request. It also says what it does not show: the second host was in the same facility zone as the source, and the service was not rebuilt from the restored copy. The next drill is due by the end of December 2026. When you evaluate, ask for the current record, and ask to watch a drill: we run it with you on the evaluation environment.
Separately, and labelled as such: a tooling drill on synthetic data is on record in the same place. It dumps a synthetic database the way the nightly job does, encrypts the dump, moves only the ciphertext, checks its hash, decrypts it, restores it into a fresh database and compares every table. A negative control shows the comparison catches a one-row change. What that gives you is a second, smaller piece of evidence that the mechanism works, and a script we send on request so you can repeat it. It restores no environment's copy; the drill above does.
Where the backup goes. The nightly job refuses, on every run, to push to a target that is not declared in Nigeria, and refuses any AWS storage endpoint even when declared: AWS has no Region in Nigeria, and the Lagos Local Zone's snapshots and buckets live in Cape Town (3.4.1). The declaration is still a declaration; how to check a target's location, and the in-country options we are choosing between, are in a design note we share on request.
4 / What an evaluation does not cover
Said plainly, because a vendor that lists only strengths is telling you about its sales process rather than its system.
5 / Getting help during an evaluation
Note what you ran, what you expected and what happened, and send it. If something in this guide does not behave as written, the guide is wrong until proven otherwise, and we would rather fix it than explain it.
*Last updated: 2026-09-24*
Questions during an evaluation go to chuma@uuamni.com. If something here does not behave as written, the guide is wrong until proven otherwise.