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
The v1 contract

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.

Authentication

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.

CredentialFormatLives onCan do
Secret keymefa_sk_live_…your servereverything
Publishable keymefa_pk_live_…an app or pagemint a session token, nothing else
Session tokenmefa_st_…the device, 15 minutessubmit 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.

Quick start

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.

Job types

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.

typeRequired inputDecision
enhanced_kycid_type (nin, vnin, bvn), id_numbernot_applicable
basic_kycid_type, id_number, personal details to matchyes
biometric_kycid_type, id_number, selfieyes
phone_verificationphone, personal detailsyes
demography_searchfirst_name, last_name, date_of_birth, gendernot_applicable
address_verificationaddress, optional geolocationyes
business_verificationregistration_numbernot_applicable
tax_id_verificationtax_id, scope of individual / corporatenot_applicable
livenessselfie or session_idyes
selfie_enrolselfie, subject_referenceyes
selfie_authselfie, subject_idyes
selfie_compareselfie, reference_imageyes
duplicate_checkselfie or subject_idyes
document_lookupdocument_type of passport / voter / vehicle, document_numbernot_applicable
aml_screeningid_type: bvn, id_number (watchlist). Name-based screening coming soonyes
account_verificationaccount_number, bank_codeyes
bank_listnonenot_applicable
card_lookupcard_binnot_applicable
meter_lookupmeter_numbernot_applicable
document_verification coming soondocument_images, selfieyes
enhanced_document_verification coming soondocument_images, selfieyes

consent is required for every type that touches a government register. Missing it is 400 consent_required.

Sandbox

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.units still 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 withResult
anything else, 0000 includedResolves with a full synthetic identity
3333No record found: not_applicable with identity_lookup failing
4444503 register_unavailable, status: failed, not billed
1111, 2222, 6666Reserved 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.

Idempotency

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.
  • reference is your business identifier and may repeat. It is not a deduplication key.
Errors

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_funds
  • 403 permission_error · 404 not_found · 409 conflict
  • 422 unprocessable · 429 rate_limited (with Retry-After)
  • 500 api_error · 501 type_not_available · 503 upstream_unavailable

code is safe to branch on; message is for humans and may change.

Resources & events

The rest of the surface

ResourcePurpose
POST /v1/verificationsSubmit a job
GET /v1/verifications/{id}Fetch one, including evidence.audit_id
GET /v1/verificationsCursor-paginated list; filter by type, decision, status, reference
POST /v1/sessionsMint 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_endpointsRegister a URL and receive a signing secret
GET /v1/eventsThe replayable event log
GET /v1/accountWallet 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 soon

Asynchronous 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.
SDKs

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.

Consent, audit and retention

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.