Sandbox demo — all sources are deterministic mocks. Use demo-public-key as your API key.
Open dashboard →
API Reference · v1

Build on Hakiki

A single HTTPS API for consent-first KYC and KYB across Tanzania's identity, business, tax, banking, and mobile-money sources. Server-to-server. Audit-ready.

Base: https://api.hakiki.co.tzLocal: http://localhost:8080

#Overview

Every verification names a subject (a person or business), the checks to run against connected sources, and the consent that authorises the lookup. The response carries an outcome for each check, a risk score, and a decision.

Multi-tenant
org-scoped keys
Reliable
idempotency keys
Auditable
append-only logs

#Sandbox status

Hakiki is currently a sandbox demo

All sources return deterministic mock data. No data is fetched from NIDA, BRELA, TRA, banks, or mobile-money operators.

  • telco_identity — sandbox mock
  • nida — sandbox mock
  • brela — sandbox mock
  • tra — sandbox mock
  • bank_account — coming soon

#Authentication

All /v1/* endpoints require an API key in the X-API-Key header. Keys are scoped to an environment (test or live) and to an organization.

requestbash
curl https://api.hakiki.co.tz/v1/sources \
  -H "X-API-Key: demo-public-key"
X-API-Keystringrequired
Your environment-scoped API key. Demo: demo-public-key.
Never expose live keys in browsers or mobile apps. Use server-to-server only. Keys can be revoked from the dashboard without breaking historical audit records.

#Create verification

POST/v1/verifications

Submit a subject with one or more checks to perform. Consent is required on every request.

example requestbash
curl -X POST http://localhost:8080/v1/verifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: demo-public-key" \
  -d '{
    "subject": {
      "kind": "person",
      "reference": "19900101123456789",
      "full_name": "Amina Juma",
      "phone": "+255712345678"
    },
    "checks": ["telco_identity", "nida"],
    "consent": {
      "method": "ussd_otp",
      "granted_at": "2026-07-04T12:00:00Z"
    }
  }'

Body parameters

subject.kind"person" | "business"required
Whether you are verifying an individual or a registered business.
subject.referencestringrequired
Subject identifier in the upstream source — NIDA number for persons, BRELA registration number for businesses.
subject.full_namestring
Display name. Used for matching and audit.
subject.phonestring
E.164 phone number. Required when telco_identity is requested.
checksstring[]required
One or more source check codes. See Sources.
consent.methodstringrequired
How the subject authorised the lookup (e.g. ussd_otp, web_form, in_branch).
consent.granted_atISO-8601 datetimerequired
The moment consent was captured. Used in audit history.
example response · 201json
{
  "id": "b12a8e3f-1a4f-4d6b-9b6a-7e7d4a1c5a92",
  "status": "completed",
  "subject": {
    "kind": "person",
    "reference": "19900101123456789",
    "full_name": "Amina Juma",
    "phone": "+255712345678"
  },
  "checks": [
    {
      "check_kind": "telco_identity",
      "outcome": "match",
      "confidence": 0.92,
      "message": "SIM registration matches subject identity"
    },
    {
      "check_kind": "nida",
      "outcome": "match",
      "confidence": 0.88,
      "message": "NIDA record found for reference"
    }
  ],
  "decision": "approved",
  "risk_score": 12,
  "risk_level": "low",
  "environment": "test",
  "created_at": "2026-07-04T12:00:01Z",
  "completed_at": "2026-07-04T12:00:02Z"
}

#Get verification

GET/v1/verifications/{id}

Retrieve a previously created verification, including its full audit timeline and webhook deliveries.

example requestbash
curl http://localhost:8080/v1/verifications/b12a8e3f-1a4f-4d6b-9b6a-7e7d4a1c5a92 \
  -H "X-API-Key: demo-public-key"
iduuidrequired
The verification identifier returned by POST /v1/verifications.

#Sources

Each check_kind routes to a specific upstream source. Use the values below in your request body.

CheckCategoryStatusSupports
telco_identity
Telco Identity
telcoSandboxperson
nida
National ID (NIDA)
identitySandboxperson
brela
Business Registry (BRELA)
businessSandboxbusiness
tra
Tax Authority (TRA)
taxSandboxperson, business
bank_account
Bank Account
bankingComing soonperson, business

Outcomes

match
Source confirms the subject
no_match
Source has no matching record
pending
Lookup still in progress
unsupported
Source not yet connected for this environment

#Risk & decisions

Every completed verification carries a deterministic risk_score (0–100) and a decision. The engine is transparent and rule-based — the same inputs always produce the same output.

Low risk
0–30
Decision: approved
Medium risk
31–69
Decision: needs_review
High risk
70–100
Decision: rejected

The verification detail screen renders a "Risk Explanation" card that surfaces which checks and flags drove the decision. This is derived from the engine output, not a separate AI service.

#Idempotency

Retry network calls safely by sending an Idempotency-Key header on POST /v1/verifications. The same key will return the original response within a 24-hour window.

examplebash
curl -X POST http://localhost:8080/v1/verifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: demo-public-key" \
  -H "Idempotency-Key: signup-flow-8f2b1c-7a3e" \
  -d '{ "subject": { ... }, "checks": ["telco_identity"], "consent": { ... } }'
A different body with the same key returns 409 Conflict. Use a new key for each distinct verification request.

#Webhooks

Subscribe to verification lifecycle events. Every delivery is signed with HMAC-SHA256 in the Hakiki-Signature header.

Event types

verification.created
A new verification was accepted
verification.completed
All checks finished
verification.approved
Decision: approved
verification.rejected
Decision: rejected
review.opened
A review case was opened
review.resolved
A reviewer resolved a case

Verifying signatures

node.jsjavascript
import crypto from 'node:crypto'

export function verifyHakikiSignature(rawBody, header, secret) {
  if (!header) return false
  const [tsPart, sigPart] = header.split(',')
  const ts = tsPart?.split('=')[1]
  const sig = sigPart?.split('=')[1]
  if (!ts || !sig) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(ts + '.' + rawBody)
    .digest('hex')

  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
}

#Rate limits

Rate limits are applied per API key. The current default is 60 requests / minute (configurable via HAKIKI_RATE_LIMIT_PER_MINUTE).

response headershttp
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1717598400
Retry-After: 12

#Errors

All errors return a JSON body with a stable error string.

error responsejson
{
  "error": "missing_field:checks"
}
StatusWhen
400Missing or invalid field in the request body
401Missing or invalid X-API-Key
409Idempotency-Key reuse with a different request body
422Requested source not enabled in this environment
429Rate limit exceeded
500Unexpected server error — safe to retry

Ready to try it?

Sign in to the sandbox dashboard, create a verification, and inspect the full audit trail.