One resource for all verification work
A new product is a new type, not a new endpoint. Every job returns a decision, every failure is an HTTP error, and the object a webhook delivers is byte-identical to the one a synchronous call returns.
- API base
- https://api.mefa.co/v1
- Dashboard
- https://app.mefa.co
- Status
- status.mefa.co
Six rules the whole API follows
One resource
POST /v1/verifications handles every product. The type field selects it; the request shape, auth and errors never change.
A decision, not a score
Every job returns approved, review or rejected. Scores and per-check bands are there for teams who want their own policy.
Failures are not decisions
A rejected identity is 200 and billable. An unreadable image is 4xx, a source outage is 503, and neither is billed.
Nothing identifying in a URL
Identity numbers travel in the request body. They never reach access logs, proxies or referrer headers.
Async is the same shape
Answered within the synchronous window you get 201 completed; otherwise 202 queued and the same object later. One parser, both paths.
Secrets never reach a device
Apps and pages hold a publishable key that can only mint a 15-minute session token scoped to one job type.
Three credentials, each with one job
Send Authorization: Bearer <key> on every request. Test credentials use _test_ and are hard-isolated: test jobs never touch production data, never bill and never appear in live listings.
| Credential | Format | Lives on | Can do |
|---|---|---|---|
| Secret key | mefa_sk_live_… | your server | everything |
| Publishable key | mefa_pk_live_… | an app or page | mint a session token, nothing else |
| Session token | mefa_st_… | the device, 15 minutes | submit capture for one job |
Keys are stored as a hash with a short display prefix. A key is shown once, in the response that creates it, and cannot be read back, including by us.
Mint a device session with a publishable key
POST /v1/sessions Authorization: Bearer mefa_pk_live_… { "type": "biometric_kyc", "reference": "signup-8841", "ttl_seconds": 900 } # → the device uses the token for one capture, then it expires { "id": "ses_01JBQ7X…", "token": "mefa_st_…", "expires_at": "2026-10-12T09:15:00Z" }
Why the token names the job type: it stops a client from minting a cheap liveness session and spending it on an expensive lookup.
Your first verification
The example below uses a test key against a sandbox fixture. Swap the key for a live one and the request is identical.
1 · Submit a job · example key, not a real credential
curl https://api.mefa.co/v1/verifications \ -H "Authorization: Bearer mefa_sk_test_EXAMPLE_KEY" \ -H "Idempotency-Key: 9f1c4e0a-7b2d-4c7e-9a1f-2e5d8c3b6a10" \ -H "Content-Type: application/json" \ -d '{ "type": "enhanced_kyc", "reference": "signup-8841", "consent": { "granted": true, "granted_at": "2026-10-12T08:59:12Z", "method": "web_form", "subject_reference": "signup-8841" }, "input": { "id_type": "nin", "id_number": "12345670000" } }'
2 · Read the result · 201, abridged
{
"id": "ver_01JBQ8M2K7…",
"object": "verification",
"type": "enhanced_kyc",
"status": "completed",
"decision": "not_applicable", // a lookup has no verdict
"livemode": false,
"checks": [
{ "name": "consent", "result": "pass" },
{ "name": "identity_lookup", "result": "pass",
"detail": { "found": true, "tier": "tier1" } }
],
"identity": { "first_name": "Sandbox",
"last_name": "Test Subject", "…": "…" },
"evidence": { "audit_id": "aud_01JBQ8M4…" },
"billing": { "billable": false, "units": 1,
"product": "enhanced_kyc" }
}
status vs decision
status is about the job: queued, processing, completed, failed. decision is about the person and only exists once the job is completed.
Lookups branch on checks
Pure lookups return decision: not_applicable. A record that was not found is identity_lookup failing with reason: "no_record_found", not rejected.
Bands, not thresholds
Each check carries pass, fail, review, skipped or errored, an optional score and a stable band. Thresholds stay ours so results are comparable.
Every product, one table
Types marked coming soon return 501 type_not_available, so a client can be written against the whole catalogue before every product is live.
type | Required input | Decision |
|---|---|---|
enhanced_kyc | id_type (nin, vnin, bvn), id_number | not_applicable |
basic_kyc | id_type, id_number, personal details to match | yes |
biometric_kyc | id_type, id_number, selfie | yes |
phone_verification | phone, personal details | yes |
demography_search | first_name, last_name, date_of_birth, gender | not_applicable |
address_verification | address, optional geolocation | yes |
business_verification | registration_number | not_applicable |
tax_id_verification | tax_id, scope of individual / corporate | not_applicable |
liveness | selfie or session_id | yes |
selfie_enrol | selfie, subject_reference | yes |
selfie_auth | selfie, subject_id | yes |
selfie_compare | selfie, reference_image | yes |
duplicate_check | selfie or subject_id | yes |
document_lookup | document_type of passport / voter / vehicle, document_number | not_applicable |
aml_screening | id_type: bvn, id_number (watchlist). Name-based screening coming soon | yes |
account_verification | account_number, bank_code | yes |
bank_list | none | not_applicable |
card_lookup | card_bin | not_applicable |
meter_lookup | meter_number | not_applicable |
document_verification coming soon | document_images, selfie | yes |
enhanced_document_verification coming soon | document_images, selfie | yes |
consent is required for every type that touches a government register. Missing it is 400 consent_required.
The same code path, against fixtures
Test keys (mefa_sk_test_…, mefa_pk_test_…) run the same contract. A test request is validated, consented, reserved, persisted and replayable exactly like a live one, and differs in three ways.
- No source is called. Nothing reaches a government register.
- Nothing is billed.
billing.unitsstill shows what the live call would cost. - No register-access audit row. That table answers "who looked up this citizen" and must never contain fixtures.
Test and live data are separated by livemode on every read. The same Idempotency-Key may be used in each mode without collision. Fixture identities are obviously synthetic: Sandbox Test Subject, [email protected].
Fixture selectors · chosen by the last four digits of the identifier
| Ends with | Result |
|---|---|
anything else, 0000 included | Resolves with a full synthetic identity |
3333 | No record found: not_applicable with identity_lookup failing |
4444 | 503 register_unavailable, status: failed, not billed |
1111, 2222, 6666 | Reserved for face-match and duplicate outcomes. On a pure lookup they return 400 fixture_not_applicable |
Every failure mode is reachable on purpose. The alternative is asking us to break production so you can see a 503 once.
Required on every POST, kept forever
Replaying a key returns the original response, original status included, with Idempotency-Replayed: true. The same key with a different body is 409 idempotency_key_reused; a key whose first request is still running is 409 idempotency_key_in_progress.
- Keys never expire, so a retry after any window cannot be billed a second time.
- The key is claimed in storage before any source call or wallet debit, so concurrent duplicates cannot double-charge.
referenceis your business identifier and may repeat. It is not a deduplication key.
Stable codes, none of them billable
{ "error": {
"type": "invalid_request",
"code": "selfie_no_face_detected",
"message": "No face was detected in the selfie.",
"param": "input.selfie",
"request_id": "req_01JBQ8P…" } }
400 invalid_request·401 authentication_error·402 insufficient_funds403 permission_error·404 not_found·409 conflict422 unprocessable·429 rate_limited(withRetry-After)500 api_error·501 type_not_available·503 upstream_unavailable
code is safe to branch on; message is for humans and may change.
The rest of the surface
| Resource | Purpose |
|---|---|
POST /v1/verifications | Submit a job |
GET /v1/verifications/{id} | Fetch one, including evidence.audit_id |
GET /v1/verifications | Cursor-paginated list; filter by type, decision, status, reference |
POST /v1/sessions | Mint a device session token |
GET /v1/subjects/{id} | An enrolled person: enrolments and past verifications |
DELETE /v1/subjects/{id} | Erase a subject and receive a receipt. Audit rows survive, as the law requires |
POST /v1/webhook_endpoints | Register a URL and receive a signing secret |
GET /v1/events | The replayable event log |
GET /v1/account | Wallet balance, rate limits, enabled products |
Rate limits are per key, per minute, and visible on GET /v1/account and in X-RateLimit-* headers. Burst allowance is separate from sustained rate so a mobile fleet reconnecting after an outage does not take itself down.
Signed webhooks
coming soonAsynchronous delivery is part of the v1 contract and is being rolled out. Until it is live, options.async returns 501 and every job answers synchronously or by polling GET /v1/verifications/{id}.
POST https://customer.example/mefa
Mefa-Signature: t=1760259554,v1=5a1f…
Mefa-Event-Id: evt_01JBQ8N…
# HMAC-SHA256 over "{t}.{raw_body}" with your endpoint secret.
# Reject timestamps older than five minutes.
# Compare in constant time. Two secrets may be active
# at once, so rotation needs no downtime.
- Events:
verification.completed·verification.failed·screening.created·subject.duplicate_detected·subject.deleted - At-least-once delivery, so handlers must be idempotent on
Mefa-Event-Id. - Retries at 1 s, 10 s, 1 m, 10 m, 1 h, 6 h, then dead-lettered to
GET /v1/events.
Capture on the device, decide on the server
The face flows (liveness, Biometric KYC, selfie enrol, authenticate, compare and duplicate detection) ship with SDKs that run the capture against a session token. Your server mints the token, the SDK does the capture, and your server reads the verification.
Web
Drop-in capture for browser onboarding flows, driven by a session token from POST /v1/sessions.
Android
Native capture with the liveness gate built in; the secret key never ships in the APK.
iOS
Native capture with the same session-token model and the same verification object on the server.
React Native
One integration for both mobile platforms, backed by the native capture modules.
SDK downloads and integration notes are provided with dashboard access.
What the contract guarantees about data
Consent is a field
consent is required for every job that touches a government register and is stored with the job. Missing it is a 400, not a warning.
Immutable audit
Every register lookup writes a row: who asked, which tenant and key, what was searched, which source tier answered, whether a record was found, and when. You receive its audit_id.
Retention you can defend
Evidence images default to 90 days with a per-tenant override up to a hard ceiling of five years. Audit rows are kept for seven years and survive subject erasure.
Internal calibration stays internal: thresholds, detector modes and model names are not in responses. Register tiers are aliased to tier1, tier2, tier3.
Get a test key and run the whole catalogue today
Fixtures for every type, no billing, the same code path as production.