Kupinga Kupinga · Developers v1.0
User manual FAQ Quickstart →
REST API · v1

Build on the Kupinga anti-fraud platform.

Score every transaction in under 300 ms, look up historical decisions, register customers, and subscribe to events. A staging-first integration package built for senior engineers with a day to prove the path end-to-end.

Base URL
https://staging.kupinga.net
Auth
API key (server) · JWT (interactive)
Latency p99
< 500 ms
Issued
May 2026

Overview

A staging-first integration package for banks and PSPs who want to validate the platform before signing the production contract.

Everything in this documentation is sized for a senior integration engineer who has the staging URL https://staging.kupinga.net, a contact email, and one to two days to prove the integration end-to-end.

What you can do with this API

  • Score transactions in real time — submit a transaction, get back an approve / decline / challenge decision in under 300 ms (p50). Per-channel routes for POS, ATM, USSD, internet banking, and mobile app.
  • Look up decisions later — every decision is durable and addressable by UUID. Supports ETag / If-None-Match for cheap revalidation.
  • Search customers — by name, email, or hashed BVN. Pull the customer 360 view (profile, KYC tier, screening status, registered accounts).
  • Subscribe to events — webhook delivery of decision.created, case.created, case.resolved, and alert.created with HMAC signing and automatic retry.

5-line quickstart

The fastest path from zero to a live decision against staging:

Bash
# 1. Log in as the integration user the platform admin provisioned for you.
curl -X POST https://staging.kupinga.net/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"tester@example.com","password":"$YOUR_PASSWORD"}'
#    => returns {"access_token":"eyJ...","refresh_token":"...","expires_in":900,...}

# 2. Mint a server-to-server API key (use the access_token from step 1).
curl -X POST https://staging.kupinga.net/api/v1/api-keys \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"name":"smoke-test","merchant_id":"DEMO_MERCHANT","tier":"standard","scopes":["evaluate","decisions:read"]}'
#    => returns {"key":"af_live_...","api_key":{...}}  -- save "key" NOW, you cannot read it again.

# 3. Score a transaction.
curl -X POST https://staging.kupinga.net/api/v1/evaluate \
  -H 'Authorization: Bearer $API_KEY' \
  -H 'Content-Type: application/json' \
  -H 'X-Idempotency-Key: 11111111-1111-1111-1111-111111111111' \
  -d '{"external_id":"smoke-001","merchant_id":"DEMO_MERCHANT","amount":12500,"currency":"NGN","channel":"pos","card_bin":"506099","terminal_id":"DEMO0001","entry_mode":"chip","emv_cryptogram_present":true}'
#    => returns {"transaction_id":"...","decision_id":"...","outcome":"approve","risk_score":...}

# 4. Read the decision back.
curl https://staging.kupinga.net/api/v1/decisions/$DECISION_ID \
  -H 'Authorization: Bearer $API_KEY'

# 5. (Optional) request a webhook subscription by emailing the contact below
#    with your callback URL, the events you want, and the IP range we should
#    expect deliveries from.

Endpoint map

Decision outcomes

risk_score ranges 0–100. The thresholds that map score → outcome are owned by the platform's rules + ML pipeline; clients should trust the outcome field rather than re-deriving from the score.

approve

band 0 – 30

Low-risk. Settle / authorise normally.

review

band 31 – 59

Suspicious. Approve, then queue for analyst review.

challenge

band 60 – 79

Step-up required. Drive the customer through OTP / biometric / PIN before settling.

decline

band 80 – 100

High-risk. Block. Show the customer a generic failure; surface specifics internally via decision_id.

Conventions used in these docs

  • Code blocks are copy-ready. The copy button in the top-right corner of each block writes the entire block to your clipboard.
  • Endpoint chips show the HTTP method (colour-coded) + path + required scope. Scope names map to the permission catalogue at internal/auth/permissions.go.
  • Hashes are 64-character lowercase hex (HMAC-SHA256 or SHA-256, depending on the field). Always send hashes, never raw PII.
  • Times are RFC 3339 in UTC unless noted otherwise.
  • Press  K (or Ctrl K) any time to search this site.

Tenant setup

What the platform operator does before handing you credentials. You don't run any of this — it lives here so you know what's already done on your behalf.

Audience This page is the platform operator's checklist. As a client integration engineer, treat it as background context — the only artefact you receive from it is the welcome email at the end.

The deliverable

The operator hands you a single email containing:

  1. The staging base URL: https://staging.kupinga.net
  2. A maker username + initial password
  3. A checker username + initial password
  4. The merchant identifier you'll send on every transaction
  5. The list of feature flags / scopes you're contracted for
  6. A link to this onboarding package

Pre-flight

Before provisioning, the operator confirms with the contract:

  • Signed DPA (data-processing agreement) on file
  • Production tier (starter / standard / premium / unlimited) negotiated
  • Channels in scope (POS-only / multi-channel)
  • Webhook URLs collected (callback host, optional IP range the platform will deliver from)
  • Production go-live date

The staging tier is always standard regardless of production tier — enough headroom to load-test without distorting the production cost picture.

Step 1 — provision the tenant row

The platform is single-tenant on the data plane (tenant_id = 1). "Tenant" here means a merchant scope. Values set at this step:

  • merchant_id — an opaque short string (e.g. BANK_ALPHA_NG). You'll send this on every /evaluate call.
  • Feature flags — screening (sanctions + PEP), ml_scoring (model registry), webhooks_self_service (admin-only today).

Step 2 — initial users

The operator creates two accounts in the admin console (/admin/users):

  • One maker (role analyst / senior_analyst) — mints API keys, runs smoke tests, configures webhooks.
  • One checker (role compliance_officer / admin) — approves key rotations and webhook changes.

The maker-checker separation is enforced at the DB layer for sensitive operations (block lifts, screening approvals, outbox replays).

Initial passwords MUST be at least 16 characters, generated by a CSPRNG (e.g. openssl rand -base64 24), communicated out-of-band (encrypted email, password-manager share — never plaintext SMS), and force a change at first login.

Step 3 — enable contracted features

Contracted featurePermission grant
Transaction evaluationtransactions:write (analyst tier inherits)
Decision lookupdecisions:read (analyst tier inherits)
API key managementapi_keys:write (must add explicitly)
Customer 360 lookupcustomers:read (analyst tier inherits)
Webhook configurationwebhooks:read (analyst) + webhooks:write (admin)

Canonical catalogue lives at internal/auth/permissions.go.

Step 4 — IP allowlisting (optional)

If you provided an IP range:

  • Mint the API key with allowed_cidrs populated (IPv4 + IPv6, up to 50 entries, CIDR or bare IP).
  • Document the range in the runbook.
  • Confirm API_KEYS_ENFORCE_ALLOWLIST=true on the tenant's fraud-engine config if you want the gateway to reject mismatches; otherwise mismatches are audited but not blocked.

Without an allowlist any IP can present the API key. Most banks require allowlisting before production sign-off; staging usually runs without it for convenience.

Step 5 — webhook scaffolding (optional)

If the contract includes webhooks, the operator pre-creates a draft subscription:

  • POST /admin/webhooks with status: draft, the client's target URL, and a secret_ref pointing to a Vault path.
  • Seed the HMAC secret in Vault at the secret_ref path.

You then PATCH /admin/webhooks/{id} with status: active once your endpoint is verified.

Step 6 — welcome email

Template
Subject: Antifraud staging access — <CLIENT_NAME>

Hi <NAME>,

Your staging access is provisioned. Endpoint:

  https://staging.kupinga.net

Credentials (please change at first login):

  Maker  : <MAKER_EMAIL>     / <INITIAL_PASSWORD>
  Checker: <CHECKER_EMAIL>   / <INITIAL_PASSWORD>

Your merchant_id is: <MERCHANT_ID>
This goes in every /evaluate request body.

Feature scope: <FEATURE_LIST>
Production tier: <TIER>

Integration package (Postman collection, examples, full docs):
  <link to /developers/>

Test plan we expect green before production cutover:
  - Postman collection passes top-to-bottom in Runner
  - Webhook signing verified against your verifier
  - Idempotency verified under retry storm
  - Capacity test at your contracted tier RPS, 5 minutes sustained

Reach out at integration@<your-domain> if anything is unclear.

— Antifraud platform team

Step 7 — log the provisioning event

Audit row in users.access_log:

SQL
INSERT INTO users.access_log
  (kind, actor_user_id, target, payload, created_at)
VALUES
  ('tenant.provisioned', <YOUR_USER_ID>,
   '<MERCHANT_ID>', '{"client":"<CLIENT_NAME>","tier":"<TIER>"}'::jsonb,
   NOW());

This is what the audit team reviews on production cutover. Don't skip it.

Creating API keys

API keys are how your server identifies itself to ours. JWTs (from /auth/login) are for the integration engineer's interactive testing; API keys are for the production rig.

POST/api/v1/api-keysapi_keys:write
GET/api/v1/api-keysapi_keys:read
DELETE/api/v1/api-keys/{public_id}api_keys:write

1. Get a JWT first

API-key creation is gated by api_keys:write and runs behind the JWT-authenticated admin surface — you can't create a key with a key. Log in first.

cURL
curl -X POST https://staging.kupinga.net/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"tester@example.com","password":"$YOUR_PASSWORD"}'

Response (truncated):

JSON
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "rt_eyJ...",
  "token_type": "Bearer",
  "expires_in": 900,
  "user": {
    "id": "0f0e0d0c-0b0a-0908-0706-050403020100",
    "email": "tester@example.com",
    "role": "admin",
    "status": "active"
  }
}

The access token is good for 15 minutes (expires_in: 900). Refresh tokens last 7 days — rotate through /auth/refresh.

2. Create the key

cURL
curl -X POST https://staging.kupinga.net/api/v1/api-keys \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "production-rig-primary",
    "merchant_id": "BANK_ALPHA_NG",
    "tier": "standard",
    "scopes": ["evaluate", "decisions:read", "customers:read"],
    "expires_at": "2027-05-25T00:00:00Z",
    "allowed_cidrs": ["203.0.113.0/24", "198.51.100.42"]
  }'

Request fields

FieldTypeRequiredMeaning
namestringyesHuman-readable label. Appears in the admin console + audit logs.
merchant_idstringyesMust match the merchant_id assigned in tenant setup. Rate-limit + audit story keys off this.
tierstringnoOne of starter, standard, premium, unlimited. Defaults standard.
scopesstring[]recommendedThe permission names this key can exercise. See Scopes below.
expires_atRFC 3339noWhen the key auto-revokes. Omit for "never" (use sparingly). 1 year is a reasonable default.
allowed_cidrsstring[]noSource-IP allowlist. CIDR (203.0.113.0/24, 2001:db8::/32) or bare IP (normalised to /32). Max 50 entries; IPv4 + IPv6. Changing after mint is a maker-checker operation.

Response shape

JSON
{
  "key": "af_live_4e6b7f3a2c8d1e5f9a0b6c2d4e8f1a3b5c7d9e0f...",
  "api_key": {
    "id": "5d8c9b0a-1234-5678-90ab-cdef01234567",
    "key_prefix": "af_live_4e6b7f3a",
    "name": "production-rig-primary",
    "merchant_id": "BANK_ALPHA_NG",
    "scopes": ["evaluate", "decisions:read", "customers:read"],
    "tier": "standard",
    "is_active": true,
    "expires_at": "2027-05-25T00:00:00Z",
    "last_used_at": null,
    "revoked_at": null,
    "created_at": "2026-05-25T11:42:13.041Z"
  },
  "created_at": "2026-05-25T11:42:13.041Z",
  "warning": "store this key securely — it cannot be retrieved again"
}
One-time disclosure The raw key field is returned ONCE and ONLY in this response. The server stores only a hash of it; subsequent GET calls return api_key (the metadata view) but never the raw value. If you lose it, you must revoke and mint a new one.

The key_prefix field is what shows up in audit logs and the admin console — useful for spotting which key generated a particular log line without exposing the whole secret.

3. Stash the key securely

Drop the raw key into a secret manager (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager — anything the rest of your platform already uses). Read it into the runtime from there, not from a config file checked into git.

Bash
export ANTIFRAUD_API_KEY="$(vault kv get -field=key secret/antifraud/staging)"

Then never echo / log / commit the value. The audit team will look for it on every routine review; finding it in a git history is a contract-violation event.

4. Scopes

Scopes pin what the key can do. The platform's permission catalogue is enumerated in internal/auth/permissions.go. The subset you typically need on a client integration:

Scope stringWhat it unlocks
evaluatePOST /api/v1/evaluate and all /api/v1/evaluate/{channel} per-channel routes.
decisions:readGET /api/v1/decisions/{id}.
customers:readGET /api/v1/customers (search), /customers/{id}, /customers/{id}/accounts.
webhooks:readGET /admin/webhooks (your own subscriptions).

Scopes follow least-privilege: if your integration only ever scores transactions and never reads them back, ask for evaluate alone. Adding customers:read "just in case" gets flagged in our reviews.

5. Tier guidance

TierSustained (per minute)BurstTypical fit
starter100 (~1.7/s)20Smoke test / POC
standard1,000 (~16.7/s)50Default. Single-branch bank, mid-sized PSP.
premium10,000 (~166.7/s)500Multi-channel bank, large PSP.
unlimitedno per-key throttlen/aTier-1 bank with contractual exemption.

Tier is enforced at the gateway. Exceeding it returns 429 with Retry-After. See rate limits & errors for the response shape.

6. Listing your keys

cURL
curl https://staging.kupinga.net/api/v1/api-keys \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Returns every key you created (admins see every key in the system). Each entry has the same api_key shape from step 2. The raw key is never in this response. Use last_used_at to spot keys that have gone stale and can be revoked.

7. Rotation

Rotate keys quarterly at minimum. Recommended flow:

  1. Mint a new key with the same scopes and tier (different name, e.g. production-rig-primary-v2).
  2. Deploy to half your fleet. Observe traffic on the new key_prefix in last_used_at.
  3. Deploy to the rest of the fleet.
  4. Wait 24 hours.
  5. Revoke the old key.

The 24-hour overlap window covers nodes that missed the rollout. Without it, those nodes start returning 401 the moment the old key dies. If a key is suspected compromised, skip the overlap and revoke immediately.

8. Revoking a key

cURL
curl -X DELETE \
  https://staging.kupinga.net/api/v1/api-keys/{public_id} \
  -H "Authorization: Bearer $ACCESS_TOKEN"

public_id is the id field from the create response (a UUID, not the key_prefix).

StatusMeaning
204Revoked.
403You're not the key's owner and not an admin. Audited as api_keys.revoke_denied (higher-priority signal than a plain permission denial).
404No key with that public_id.

A revoked key returns 401 invalid_credentials on every subsequent request. Revocation is immediate; no caching, no propagation delay.

Authentication

Two credential types, one wire format. Both present as Authorization: Bearer <token>.

CredentialIssued byLifetimeUse case
JWT access tokenPOST /api/v1/auth/login15 minutes (configurable)Interactive testing by the integration engineer. The admin UI uses this too.
API keyPOST /api/v1/api-keys (behind a JWT)Up to its expires_at (or forever, if unset)Production server-to-server calls.

The platform's auth middleware tries JWT verification first; if that fails, it tries API-key verification. Most production traffic hits the API-key path on the first attempt.

API key (server-to-server)

cURL
curl https://staging.kupinga.net/api/v1/evaluate \
  -H "Authorization: Bearer af_live_4e6b7f3a2c8d1e5f9a0b..." \
  -H "Content-Type: application/json" \
  -d '{"external_id":"...","merchant_id":"BANK_ALPHA_NG","amount":12500,"currency":"NGN","channel":"pos","card_bin":"506099"}'

That's all. No nonce, no signature header, no body hashing — TLS 1.2+ is the transport security. The platform refuses cleartext HTTP (requireSecureTransport middleware responds 403).

Recommended client patterns

  • Store the key in your secret manager, not in source control. Read it at boot via the secret manager's SDK (Vault Agent, AWS Secrets Manager, GCP Secret Manager).
  • Pin the key into one process, not the whole node. A per-process env variable beats a shared /etc/antifraud/key file.
  • Mask the key in your logs. Print only the prefix (af_live_4e6b7f3a) when you need to identify which key issued a request.
  • Watch last_used_at. A production key untouched for 7 days is probably orphaned.

IP allowlisting

Each API key carries an optional allowed_cidrs list — a set of CIDR ranges (IPv4 or IPv6, or single addresses normalised to /32 / /128). At authentication time the gateway compares the caller IP to that list:

  • Empty list — any source IP can present the key. Default for new keys unless you ask otherwise.
  • Non-empty list, IP in range — request proceeds normally.
  • Non-empty list, IP not in range — gateway returns 401 invalid_credentials, the exact same shape as a wrong-key response. We deliberately do not distinguish the two cases; a stolen key cannot be probed against the allowlist boundary to confirm validity.

Enforcement is gated per-environment by API_KEYS_ENFORCE_ALLOWLIST. Until that switch is on in your environment, the gateway records mismatches in the audit log but does not block.

Changing an allowlist after mint Mutating a live key is a two-person operation. Step 1: any operator with api_keys:write calls POST /api/v1/api-keys/{public_id}/allowlist-changes with the proposed CIDR list + a reason. Step 2: a different operator approves via POST /api/v1/api-keys/allowlist-changes/{change_id}/approve. Same-user approval is rejected by the database. Every change is versioned in users.api_key_allowlist_changes.

JWT (interactive)

POST/api/v1/auth/login
POST/api/v1/auth/refresh
POST/api/v1/auth/logout
cURL
# Login — exchanges email+password for an access+refresh pair
curl -X POST https://staging.kupinga.net/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"tester@example.com","password":"$YOUR_PASSWORD"}'

# Refresh — burns the supplied refresh token and returns a new pair
curl -X POST https://staging.kupinga.net/api/v1/auth/refresh \
  -H 'Content-Type: application/json' \
  -d '{"refresh_token":"rt_eyJ..."}'

# Logout — invalidates the refresh token
curl -X POST https://staging.kupinga.net/api/v1/auth/logout \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"refresh_token":"rt_eyJ..."}'

Login response

JSON
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "rt_eyJ...",
  "token_type": "Bearer",
  "expires_in": 900,
  "user": {
    "id": "5d8c9b0a-1234-5678-90ab-cdef01234567",
    "email": "tester@example.com",
    "first_name": "Test",
    "last_name": "Engineer",
    "display_name": "Test Engineer",
    "role": "admin",
    "status": "active"
  }
}

Important JWT behaviours

  • 15-minute access token. Once it expires every call returns 401 token_expired. Use the refresh endpoint; don't re-login.
  • Refresh-token rotation. Every successful /auth/refresh invalidates the supplied refresh token and issues a fresh pair. Using the same refresh token twice fails with token_revoked — that's the rotation-replay defence.
  • Permission re-read on refresh. When refresh succeeds, the new access token carries your current permission set from the DB, not the set you had at the original login. A permission granted by the admin between logins is picked up on the next refresh.
  • Account lockout. Five consecutive failed logins lock the account for 15 minutes. The wire response is identical to bad-password to avoid leaking which users are deactivated.
  • Login rate limit. 10 attempts per minute per IP. Exceeding returns 429.

Logout

POST /auth/logout invalidates the refresh token (blacklist with TTL = the token's remaining lifetime). The access token is still valid until it expires naturally (15 minutes); there is no server-side revocation list for access tokens because the short TTL makes it unnecessary.

Which one should I use?

  • Integration / smoke testing from a developer laptop → JWT. Log in as the maker user the platform admin provisioned.
  • Postman against staging → JWT. The collection's Login request populates {{accessToken}} automatically.
  • Production server posting transactions → API key. Mint one per environment (staging + production = two keys minimum).
  • CI smoke job against staging → API key. No password to manage, no expiry to worry about.

Don't mix the two on the same call. The platform tries JWT first; if you present an API key shaped like af_live_... in the JWT position, the JWT verifier fails fast and the API-key verifier takes over, but the failure path costs an extra ~1 ms — not enough to matter for low volume, enough to show up in a load test.

Failure modes

StatusCodeMeaning
401missing_authenticationNo Authorization header.
401invalid_credentialsHeader present but the token/key didn't verify. Could be: wrong token, expired token, revoked key, IP not in allowlist.
401token_expiredJWT-specific. Refresh and retry.
401token_revokedRefresh-token replay. Force the user to log in again.
403forbiddenAuthenticated but lacks the required permission/scope.

The 401 vs 403 distinction matters for retry logic: 401 is an authentication failure (you can't keep retrying with the same credential); 403 is an authorisation failure (your credential is valid but the operation isn't allowed — don't retry, escalate).

Evaluating transactions

The endpoint your integration spends most of its time talking to.

POST/api/v1/evaluateevaluate
POST/api/v1/transactions/ingestevaluate
POST/api/v1/evaluate/posevaluate
POST/api/v1/evaluate/atmevaluate
POST/api/v1/evaluate/ussdevaluate
POST/api/v1/evaluate/mobile-appevaluate
POST/api/v1/evaluate/internet-bankingevaluate
POST/api/v1/evaluate/nipevaluate
POST/api/v1/evaluate/rtgsevaluate
POST/api/v1/evaluate/intra-bankevaluate
POST/api/v1/evaluate/agentevaluate
POST/api/v1/evaluate/walletevaluate

Per-channel routes are functionally equivalent to POST /evaluate with the channel pinned from the URL. They give you slightly clearer per-channel error messages ("terminal_id is required for POS" rather than the generic card-channel error) and run inside a per-channel rate-limit bucket. Use them when you can; use the catch-all /evaluate when one endpoint has to handle every channel.

Required fields

Every field maps to a column on core.transactions. Length limits mirror DB column widths; a payload that validates here will not trip a database constraint at insert time.

FieldTypeValidationMeaning
external_idstringrequired, max 255Your unique identifier for this transaction. The (merchant_id, external_id) pair is enforced unique — re-submitting the same pair returns the cached decision.
merchant_idstringrequired, max 64The merchant identifier the platform admin assigned you. Must match the API key's merchant_id.
amountnumberrequired, ≥ 0Transaction amount in the major unit of currency (e.g. 12500 NGN = twelve thousand five hundred naira).
currencystringrequired, ISO 4217Three-letter code: NGN, USD, EUR, etc.

Channel & payment context (recommended)

FieldTypeMeaning
channelstringOne of: nip, rtgs, intra_bank, card_present, card_cnp, web, mobile, ussd, ach, pos, atm, mobile_app, internet_banking, agent_banking, wallet_transfer, nqr, cheque. Driving from the per-channel URL is preferred.
transaction_typestringdebit, credit, transfer, payment, withdrawal, deposit, refund, reversal, fee, interest, inquiry.
statusstringLifecycle hint: pending, successful, failed, cancelled, reversed, on_hold, declined.
payment_methodstringFree-form, max 32.
transaction_referencestringYour reference, max 64.

Customer

FieldTypeMeaning
customer_idstringYour customer identifier, max 64. The first time the platform sees it, a stub customer record is created automatically.
customer_emailstringOptional, must look like an email.
customer_phonestringOptional, max 32. E.164 preferred.
bvn_hashstringHMAC-SHA256 of the customer's BVN. 64 hex chars. Never send the raw BVN — see PII rules.

Card

FieldTypeMeaning
card_binstringFirst 6 or 8 digits. Required for pos, atm, card_present, card_cnp.
card_last_fourstringExactly 4 digits.
card_brandstringvisa, mastercard, verve, etc.
card_typestringcredit, debit, prepaid.
card_countrystringISO 3166-1 alpha-3.
entry_modestringchip, contactless, magstripe, keyed, ecommerce, fallback, credential_on_file, unknown.
emv_cryptogram_presentboolRequired true when entry_mode is chip or contactless. EMV liability rule.
pos_pin_verifiedbool
pos_signature_verifiedbool

Terminal / POS

FieldTypeMeaning
terminal_idstringUp to 16 chars. Required for POS.
terminal_countrystringISO 3166-1 alpha-3.
terminal_location_latnumber−90 to 90.
terminal_location_lngnumber−180 to 180.
merchant_namestringUp to 255.
merchant_citystringUp to 100.
merchant_countrystringISO 3166-1 alpha-3.
mccstringMerchant Category Code. Validated against the MCC tag.

ATM (channel = atm)

FieldTypeMeaning
atm_idstringRequired. Up to 20 chars. Cross-resolved against the registry; unknown atm_id returns 404 UNKNOWN_ATM.
withdrawal_amountnumber≥ 0.
atm_location_latnumber−90 to 90.
atm_location_lngnumber−180 to 180.
pin_attemptsnumber0–9.
is_foreign_cardbool
transaction_subtypestringwithdrawal, balance_inquiry, mini_statement, transfer, deposit.

Account-to-account (NIP / RTGS / intra_bank / ACH / cheque)

These channels require both source AND destination identifiers. The platform accepts two equivalent field families — either works:

AF-105 form (preferred)AF-200 form (legacy)
source_account_numbersender_nuban
source_account_namesender_account_name
source_bank_codesender_bank_id (int FK form)
dest_account_numberbeneficiary_nuban
dest_account_namebeneficiary_name
dest_bank_codebeneficiary_bank_id (int FK form)

The 10-digit NUBAN is validated with the CBN check-digit algorithm when its bank code is also supplied. A mismatch returns 422 with code: nuban. Bank codes: 3 or 6 chars. Unknown bank codes return 404 UNKNOWN_BANK.

Other A2A fields: narration (max 255), nip_session_id (30/31/32 chars), stan (6–12 numeric chars), transaction_reference (max 64).

Device / network

FieldTypeMeaning
device_idstringUp to 128 chars. For mobile_app, also derived from X-Device-ID header.
device_type, device_os, device_browserstringSelf-explanatory.
app_versionstringUp to 40 chars. For mobile_app, also derived from X-App-Version header (header wins).
biometric_usedboolmobile_app only.
biometric_typestringnone, face, fingerprint.
screen_locked_attemptsnumber≥ 0.
ip_addressstringValidated as a parseable IP.
ip_country, ip_region, ip_citystring
is_vpn, is_proxybool

Free-form

FieldTypeMeaning
billing_address, shipping_addressobjectFree-form JSON.
metadataobjectFree-form JSON. Persisted on the row, available for downstream rules + analytics.

Wallet / agent banking (channel = wallet_transfer / agent_banking)

FieldTypeMeaning
agent_idstringNIBSS super-agent / sub-agent code. Up to 40 chars.
wallet_providerstringopay, palmpay, moniepoint, kuda, carbon, fairmoney, cowrywise, sparkle, mtn_momo, airtel_smartcash, 9psb, hopepsb, pocketapp, other.
psb_transaction_subtypestringcash_in, cash_out, p2p_wallet, wallet_to_bank, bank_to_wallet, airtime, bill.

Amounts / status / session (optional)

These fields don't drive routing or detection on their own, but they are persisted on core.transactions and available to downstream analytics, reconciliation, and audit-chain replay.

FieldTypeValidationMeaning
balance_beforenumber≥ 0Customer balance immediately before the transaction posted.
balance_afternumber≥ 0Customer balance after the transaction posted.
fee_amountnumber≥ 0Fee charged on top of amount.
vat_amountnumber≥ 0VAT applied to the fee (or to amount for VAT-bearing services).
session_idstringmax 64Your client-side session identifier. Useful for grouping a customer's actions across multiple /evaluate calls.
completed_attimestampRFC 3339When the transaction reached terminal status on your side. Defaults to the platform's receive time.
status_reasonstringmax 255Human-readable reason accompanying status (e.g. insufficient_funds, customer_cancelled).

PII rules

Never send raw PII BVN, PAN, NIN. The platform's logger redacts BVN-shaped strings at the writer layer, but the contract is don't-send, not rely-on-redaction.
  • Never send raw BVN. Use bvn_hash (HMAC-SHA256, 64 hex chars). The HMAC key is the platform pepper, shared out-of-band during onboarding.
  • Never send raw PAN. Use card_bin (first 6–8 digits) and card_last_four. The platform never asks for the middle of the PAN.
  • NUBANs are not PII in the same sense, but they ARE customer identifiers — treat them with the same care.

Idempotency

Re-submitting the same logical transaction (network retry, app crash + restart) must not create duplicate rows. Two layers:

Layer 1 — X-Idempotency-Key header

Recommended. Send a UUIDv4 in the X-Idempotency-Key request header. The platform stores the response under this key for 24 hours; a subsequent request with the same header replays the cached response and sets X-Idempotent-Replay: true on the way out.

cURL
curl -X POST https://staging.kupinga.net/api/v1/evaluate \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: 11111111-1111-1111-1111-111111111111" \
  -d '{...}'
OutcomeStatusWire shape
First call with this key200The full DecisionResponse.
Replay (same key, same body)200Cached DecisionResponse, header X-Idempotent-Replay: true.
Conflict (same key, different body)409code: idempotency_conflict.
Still in flight409code: idempotency_in_flight. Retry after a brief backoff.

The key is scoped to the calling merchant_id — two merchants can reuse the same UUID without colliding.

Layer 2 — (merchant_id, external_id) uniqueness

Backstop. Even without the header, the DB enforces uniqueness on (merchant_id, external_id). A second submission of the same pair returns 409 duplicate_transaction with the cached decision; the response carries X-Idempotent: true.

Combined behaviour

If you can generate a stable X-Idempotency-Key from your client state (transaction ID, retry-attempt counter, etc.), use it — it gives you the cleanest 200 replay for retries. If you can't, external_id alone is sufficient: the 409 reply still carries the original decision, so your client logic can branch on the status code and use the same outcome field either way.

Recommended pattern Pin X-Idempotency-Key to your own canonical transaction UUID, and pin external_id to the same value. You then get both the 200-replay fast path AND the 409-with-decision slow path, no matter which one wins.

Rules-engine variable reference

The exhaustive catalogue of what the detection layer actually reads off your payload.

Three things consume the request body once it clears validation: the rules engine (declarative DSL evaluated by the compiler), the velocity tracker (Redis counters keyed by dimension), and the list checker (blocklist / whitelist / watchlist lookups against entity types). Each reads a different subset of the input — the tables below tell you which fields drive which behaviour.

How to read these tables If a field is documented in /evaluate but isn't listed here, it's stored on the row but not consumed by detection today (analytics + audit only). Every variable currently in the catalogue is populated by the runtime; rules that reference any of them resolve against real data.

1. Rules-engine variable catalogue

Every variable a rule may reference in the DSL, where it comes from, and whether it is populated today. Source: internal/rules/schema/.

Variable (rule DSL)TypeSourced from request fieldSourced from entityPopulated?
transaction.amountnumberamountcore.transactions.amountYes
transaction.currencystringcurrencycore.transactions.currencyYes
transaction.channelstringchannel (or URL channel)core.transactions.channelYes
transaction.merchant_idstringmerchant_idcore.transactions.merchant_idYes
transaction.payment_methodstringpayment_methodcore.transactions.payment_methodYes
transaction.transaction_typestringtransaction_typecore.transactions.transaction_typeYes
transaction.directionstringtransaction_type (alias)core.transactions.transaction_typeYes
transaction.countrystringmerchant_countrycore.transactions.merchant_countryYes
transaction.descriptionstringnarrationcore.transactions.narrationYes
transaction.card_binstringcard_bincore.transactions.card_binYes
transaction.card_last_fourstringcard_last_fourcore.transactions.card_last_fourYes
transaction.card_brandstringcard_brandcore.transactions.card_brandYes
transaction.card_typestringcard_typecore.transactions.card_typeYes
transaction.card_countrystringcard_countrycore.transactions.card_countryYes
transaction.is_foreign_cardboolderived: card_country != "" && card_country != "NG"Yes (derived)
transaction.terminal_idstringterminal_idcore.transactions.terminal_idYes
transaction.atm_idstringatm_idcore.transactions.atm_idYes
transaction.entry_modestringentry_modecore.transactions.entry_modeYes
transaction.pin_attemptsnumberpin_attemptscore.transactions.pin_attemptsYes
transaction.atm.deployment_typestring(enriched from ATM registry by ATMRegistryResolver)Yes
transaction.sender_nubanstringsender_nubancore.transactions.sender_nubanYes
transaction.beneficiary_nubanstringbeneficiary_nubancore.transactions.beneficiary_nubanYes
transaction.source_bank_codestringsource_bank_codecore.transactions.source_bank_codeYes
transaction.dest_bank_codestringdest_bank_codecore.transactions.dest_bank_codeYes
transaction.source_account_numberstringsource_account_numbercore.transactions.source_account_numberYes
transaction.dest_account_numberstringdest_account_numbercore.transactions.dest_account_numberYes
transaction.narrationstringnarrationcore.transactions.narrationYes
transaction.device_idstringdevice_id (or X-Device-ID header)core.transactions.device_idYes
transaction.ip_addressstringip_addresscore.transactions.ip_addressYes
transaction.user_agentstringcaptured from r.UserAgent() at the HTTP boundary (transient — not persisted)Yes
transaction.session_idstringsession_idcore.transactions.session_idYes
customer.idstringcustomer_idcore.customers.customer_numberYes
customer.emailstringcustomer_emailcore.customers.emailYes
customer.phonestringcustomer_phonecore.customers.phoneYes
customer.kyc_tierstring(from customer record)core.customers.kyc_tierYes
customer.is_pepbool(from customer record)core.customers.is_pepYes
customer.risk_scorenumber(from customer record)core.customers.risk_scoreYes
customer.fraud_countnumber(BVN-fingerprint derived via identity.Service)Yes
customer.account_age_daysnumber(BVN-fingerprint derived via identity.Service)Yes

A variable may still resolve to null at evaluation time if its source field is absent on a specific transaction — e.g. transaction.user_agent is null when the inbound HTTP request had no User-Agent header, and the BVN-fingerprint fields are null when the platform couldn't resolve a fingerprint for the customer (Tier 1 KYC, lookup error, or a 50 ms timeout on the hot path). A rule comparing a null variable with any operator other than == / != evaluates to false and short-circuits, so it's safe to reference these variables without guarding for nullability.

2. Velocity dimensions

The velocity tracker pivots on eleven dimensions. Each is incremented for every accepted transaction that supplies a non-empty value for the corresponding field(s). Source: internal/velocity/dimensions.go.

DimensionSourced from request field(s)Composite keyHashed?
usercustomer_idNo (opaque ID)
devicedevice_idNo (SDK-generated)
ipip_addressYes (SHA-256)
cardcard_bin + card_last_four<bin>:<last4>Yes (SHA-256)
emailcustomer_email (lowercased)Yes (SHA-256)
phonecustomer_phoneYes (SHA-256)
agentagent_idNo
terminalterminal_idNo
merchantmerchant_idNo
sender_accountsource_bank_code + source_account_number, OR sender_bank_id + sender_nuban<bank>:<nuban>Yes (SHA-256)
beneficiary_accountdest_bank_code + dest_account_number, OR beneficiary_bank_id + beneficiary_nuban<bank>:<nuban>Yes (SHA-256)

A dimension is skipped (no counter increment) when its source field is empty. If you want a VELOCITY_COUNT_24H reason code to fire on a phone number, you have to send customer_phone. The fan-out detector (FRD_NIP_FANOUT) keys off the sender_account / beneficiary_account dimensions and won't fire on A2A transactions that omit the source/destination identifiers.

3. List entity types

The list checker (internal/lists/) supports thirteen entity types. A transaction is checked against every type for which it carries a value; a match against a blocklist usually pins the outcome to decline regardless of the numeric score.

Entity typeSourced from request field(s)Hashed before lookup?
usercustomer_idNo
customercustomer_idNo
devicedevice_idNo
cardcard_bin + card_last_fourYes
ipip_addressYes
emailcustomer_emailYes
phonecustomer_phoneYes
merchantmerchant_idNo
terminalterminal_idNo
bvnbvn_hash (caller supplies the hash)Yes
nubansender_nuban / source_account_number / dest_account_number / beneficiary_nubanYes
account_bank_pairsource_bank_code + source_account_number (or sender alias)Yes
beneficiary_accountdest_bank_code + dest_account_number (or beneficiary alias)Yes

4. Channel-required fields at a glance

Per-channel required vs. recommended vs. ignored fields. "Recommended" means a field that isn't required by the validator but unlocks specific reason codes when populated.

ChannelRequiredRecommended (drives detection)Ignored
poscore + card_bin + terminal_idcard_last_four, entry_mode, emv_cryptogram_present, pos_pin_verified, terminal_country, terminal_location_lat/lng, merchant_country, mccA2A fields
atmcore + card_bin + atm_idcard_last_four, entry_mode, pin_attempts, withdrawal_amount, atm_location_lat/lng, is_foreign_card, transaction_subtypeA2A fields, terminal_*
card_present / card_cnpcore + card_bincard_last_four, entry_mode, card_country, merchant_country, mcc, ip_address (CNP)A2A fields, atm_id
nip / rtgs / intra_bank / ach / chequecore + (source NUBAN + dest NUBAN, either form) + matching bank codesnarration, nip_session_id, stan, transaction_referencecard_*, atm_*, terminal_*
ussdcorecustomer_phone, session_id, transaction_type, plus A2A for transferscard_*, atm_*, terminal_*
mobile_appcoredevice_id (or X-Device-ID), app_version (or X-App-Version), biometric_used, biometric_type, screen_locked_attempts, ip_addresscard_*, atm_*, terminal_*
internet_bankingcoredevice_id, ip_address, is_vpn, is_proxy, device_browser, session_idcard_*, atm_*, terminal_*
agent_bankingcore + (A2A for transfers)agent_id, psb_transaction_subtype, terminal_id if super-agent devicecard_*
wallet_transfercorewallet_provider, psb_transaction_subtype, A2A for wallet-to-bank, device_id, ip_addresscard_*, atm_*
webcoreip_address, device_id, ip_country, is_vpn, is_proxy, card_* for CNPatm_*
nqrcoremerchant_id, terminal_id, mccatm_*, card_*
Take-away A payload carrying just the required four fields can still be evaluated, but it will only fire amount-based rules and merchant-level velocity. Every additional optional field unlocks a specific slice of the detection surface — the tables above are the key for deciding which ones to populate for your channel.

/evaluate · examples & reference

Per-channel rate limits, worked examples, the DecisionResponse shape, risk-score bands, latency budgets, and failure modes.

Per-channel rate limits

In addition to the per-key tier limit, each /evaluate/{channel} route has its own bucket per caller:

ChannelDefault sustained RPS (per minute)Burst
POS2,000200
ATM1,000100
USSD5,0001,000
Mobile app3,000300
Internet banking1,500150
NIP4,000400
RTGS20020
Intra-bank2,000200
Agent banking2,000500
Wallet transfer3,000300

These match the realistic peak shape per channel (USSD gets a big burst because salary-day spikes are normal traffic; RTGS gets a small bucket because it's low-volume by definition). Exceeding any of them returns 429 with Retry-After.

Example 1 — POS card-present → expect APPROVE

cURL
curl -X POST https://staging.kupinga.net/api/v1/evaluate/pos \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: 11111111-1111-1111-1111-111111111111" \
  -d '{
    "external_id": "demo-pos-001",
    "merchant_id": "BANK_ALPHA_NG",
    "amount": 12500,
    "currency": "NGN",
    "customer_id": "demo-cust-001",
    "channel": "pos",
    "transaction_type": "payment",
    "card_bin": "506099",
    "card_last_four": "0000",
    "card_brand": "verve",
    "card_type": "debit",
    "entry_mode": "chip",
    "emv_cryptogram_present": true,
    "pos_pin_verified": true,
    "terminal_id": "TERM0001",
    "terminal_country": "NGA",
    "merchant_name": "DEMO MERCHANT LAGOS",
    "merchant_city": "Lagos",
    "merchant_country": "NGA",
    "mcc": "5411"
  }'
Response (truncated)
{
  "transaction_id": "9d5e1b3c-1a2b-4c5d-6e7f-8a9b0c1d2e3f",
  "decision_id": "7c4d2a3b-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
  "outcome": "approve",
  "risk_score": 12,
  "reason_codes": [],
  "recommended_actions": [],
  "processing_time_ms": 87,
  "request_id": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e"
}

Example 2 — High-velocity unusual-geo card → expect CHALLENGE

cURL
curl -X POST https://staging.kupinga.net/api/v1/evaluate/pos \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: 22222222-2222-2222-2222-222222222222" \
  -d '{
    "external_id": "demo-pos-002",
    "merchant_id": "BANK_ALPHA_NG",
    "amount": 850000,
    "currency": "NGN",
    "customer_id": "demo-cust-002",
    "channel": "pos",
    "transaction_type": "payment",
    "card_bin": "539923",
    "card_last_four": "0001",
    "card_brand": "mastercard",
    "card_type": "credit",
    "card_country": "NGA",
    "entry_mode": "magstripe",
    "terminal_id": "TERM9999",
    "terminal_country": "RUS",
    "merchant_name": "DEMO MERCHANT MOSCOW",
    "merchant_city": "Moscow",
    "merchant_country": "RUS",
    "mcc": "7995",
    "ip_address": "203.0.113.42",
    "ip_country": "RUS"
  }'
Response (truncated)
{
  "transaction_id": "9d5e1b3c-1a2b-4c5d-6e7f-8a9b0c1d2e40",
  "decision_id": "7c4d2a3b-5e6f-7a8b-9c0d-1e2f3a4b5c6e",
  "outcome": "challenge",
  "risk_score": 68,
  "reason_codes": ["UNUSUAL_GEO", "HIGH_RISK_MCC", "MAGSTRIPE_FALLBACK"],
  "recommended_actions": ["step_up_otp", "notify_customer"],
  "challenge": {
    "challenge_type": "otp",
    "instructions": "An OTP has been sent to the customer's registered phone. Enter it to continue.",
    "challenge_id": "ch_2f3a4b5c6d7e8f9a",
    "delivery": "sms",
    "deadline_at": "2026-05-25T11:47:00Z",
    "verify_url": "/api/v1/challenge/ch_2f3a4b5c6d7e8f9a",
    "max_attempts": 3
  },
  "processing_time_ms": 142,
  "request_id": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6f"
}

When outcome=challenge, your client should drive the customer through the indicated challenge_type (OTP, biometric, PIN, secret question, etc.) and only complete the transaction when the verify_url returns success.

Example 3 — Known-bad merchant + sanctioned counterparty → expect DECLINE

cURL
curl -X POST https://staging.kupinga.net/api/v1/evaluate/nip \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: 33333333-3333-3333-3333-333333333333" \
  -d '{
    "external_id": "demo-nip-001",
    "merchant_id": "BANK_ALPHA_NG",
    "amount": 4500000,
    "currency": "NGN",
    "customer_id": "demo-cust-003",
    "channel": "nip",
    "transaction_type": "transfer",
    "source_account_number": "0123456789",
    "source_account_name": "DEMO CUSTOMER ALPHA",
    "source_bank_code": "044",
    "dest_account_number": "9876543210",
    "dest_account_name": "SANCTIONED ENTITY LTD",
    "dest_bank_code": "058",
    "narration": "invoice payment",
    "nip_session_id": "044202605251142000123456789012"
  }'
Response (truncated)
{
  "transaction_id": "9d5e1b3c-1a2b-4c5d-6e7f-8a9b0c1d2e41",
  "decision_id": "7c4d2a3b-5e6f-7a8b-9c0d-1e2f3a4b5c6f",
  "outcome": "decline",
  "risk_score": 95,
  "reason_codes": ["SANCTIONS_HIT", "STRUCTURED_AMOUNT", "BENEFICIARY_HIGH_RISK"],
  "recommended_actions": ["reject_transaction", "open_case", "file_sar"],
  "processing_time_ms": 118,
  "request_id": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d70"
}
Don't expose decline reasons to the customer When outcome=decline, the reason_codes are for your operations team. Show the customer a generic "transaction could not be completed" message; the detail goes in your internal audit log alongside the decision_id.

Response shape — DecisionResponse

JSON
{
  "transaction_id": "uuid",   // platform's transaction public_id
  "decision_id":   "uuid",   // platform's decision public_id
  "outcome":       "approve|review|challenge|decline",
  "risk_score":    0..100,
  "reason_codes":  ["UPPER_SNAKE_CASE", ...],   // empty array when no signal fired
  "recommended_actions": ["snake_case", ...],   // empty array when none
  "challenge":     null | {
    "challenge_type": "otp|otp_sms|otp_email|biometric|pin|secret_question|manual_review",
    "instructions":   "human-readable hint, safe to render verbatim",
    "metadata":       null | {"key": "value"},   // channel-specific hints
    "challenge_id":   "ch_...",                  // realtime plane (when wired)
    "delivery":       "sms|push|...",            // realtime plane
    "deadline_at":    "RFC 3339",                // realtime plane
    "deeplink":       "antifraud://...",         // realtime plane (mobile only)
    "verify_url":     "/api/v1/challenge/...",   // realtime plane
    "max_attempts":   1..5                       // realtime plane
  },
  "processing_time_ms": 87,
  "request_id":        "uuid"   // chi request ID, also on X-Request-Id
}

Field rules:

  • transaction_id, decision_id, outcome, risk_score, processing_time_ms are ALWAYS present.
  • reason_codes and recommended_actions are always present but may be empty arrays.
  • challenge is omitted unless outcome == "challenge". Branch on the field's presence, not on re-deriving from the outcome.
  • request_id is omitted only in tests / internal-call contexts; production traffic always carries it.

Risk-score reference

risk_score is an integer in [0, 100]. Higher = riskier. The score is derived from rules + ML + signal weights; the outcome is chosen by mapping the score against per-channel thresholds.

Default bands (global fallback)

When a channel has no custom threshold row configured, the platform uses these defaults:

approve

0 – 30

Clean. Proceed with the transaction; log the decision and move on.

review

31 – 59

Suspicious but not blockable inline. Approve, then queue for analyst review.

challenge

60 – 79

Step-up required. Surface the challenge.challenge_type; do NOT settle until challenge succeeds.

decline

80 – 100

Block. Reject. Show a generic failure to the customer; surface specifics via decision_id.

Source: config.decision_thresholds (defaults seeded in migration 0020). All comparisons are inclusive: a score of exactly 30 approves, exactly 60 challenges, exactly 80 declines.

Per-channel overrides

Thresholds are configurable per channel (POS, NIP, ATM, USSD, Mobile, Internet, Card). A risk-averse channel can move decline_min down to 70; a high-volume low-friction channel can move approve_max up to 40. The four outcomes don't change — only the bands that map to them do.

If you operate across multiple channels, expect different score-to-outcome mappings. Always branch on outcome, not on raw risk_score. The score is informational; the outcome is the decision the platform has already made for you.

Reason-code anchors

Common reason_codes you'll see alongside elevated scores:

CodeCategoryWhen it fires
AMOUNT_HIGHAmountTransaction amount exceeds customer-segment norm.
AMOUNT_ROUND_NUMBERAmountSuspiciously round value (typical of mule cash-out).
BEHAVIOR_OFF_HOURSBehaviorOutside the customer's typical active window.
BEHAVIOR_NEW_PAYEEBehaviorFirst-time recipient for this customer.
DEVICE_NEWDeviceFirst sighting of this device for the customer.
DEVICE_FINGERPRINT_MISMATCHDeviceDevice hash doesn't match the cached fingerprint.
GEO_HIGH_RISK_COUNTRYGeoIP / merchant geo is on the elevated-risk list.
GEO_IP_VELOCITYGeoSame IP has driven many distinct accounts recently.
VELOCITY_COUNT_24HVelocityCustomer's transaction count in 24h exceeds tier norm.
LIST_BLOCKLIST_PANListPAN matches an internal blocklist.
LIST_SANCTIONSListSanctions-list hit. Usually drives decline regardless of score.
ML_HIGH_RISKMLModel output exceeded the configured ML threshold.
FRD_NIP_FANOUTGraphMule-detector: account is fanning funds across many beneficiaries.
FRD_MOBILE_NEW_DEVICEDeviceNewly-bound device on a transfer (account-takeover signal).

The full catalogue lives behind GET /api/v1/decisioning/reason-codes once you have decisions:read scope; the table above is the operational shortlist worth handling explicitly in your UI.

Outcome overrides Some codes (notably LIST_SANCTIONS and certain regulatory blocks) drive the outcome directly regardless of the numeric score. Don't assume outcome and risk_score are perfectly correlated — they agree most of the time, but rule overrides can pin an outcome.

Customer-level vs decision-level score

The Customer object also carries risk_score + risk_tier (low / medium / high). That's a profile-level snapshot maintained by KYC / screening, not a rolling per-transaction aggregate. Treat it as a slow-moving customer attribute. The per-/evaluate risk_score is the realtime decision score and is the one to act on at transaction time.

Latency expectations

PercentileBudget
p50< 300 ms
p95< 450 ms
p99< 500 ms
p99.9< 1,500 ms

The synchronous path runs validation → idempotency lookups (Redis + DB) → enrichment → rules + ML pipeline → persistence → response. The pipeline itself has a 100 ms internal budget; the rest is network + DB round-trips.

If your client's observed latency is materially above these numbers, check: (1) your network path to https://staging.kupinga.net; (2) whether you're hitting the rate limit; (3) the processing_time_ms field on the response — that's the server's view. If your wall-clock differs from it by more than ~200 ms, the gap is in the network.

Failure modes

See rate limits & errors for the full catalogue. The ones specific to /evaluate:

StatusCodeMeaning
400invalid_json / invalid_bodyRequest body wasn't valid JSON or couldn't be read.
404UNKNOWN_BANKA bank code field (source_bank_code / dest_bank_code) doesn't match the platform's bank registry.
404UNKNOWN_ATMatm_id doesn't match the registry.
409duplicate_transaction(merchant_id, external_id) already exists. Response body carries the cached decision.
409idempotency_conflictSame X-Idempotency-Key, different body.
409idempotency_in_flightA prior request with the same key hasn't finished yet. Retry shortly.
422validation_errorWire-shape problem. The details array lists each offending field.

Looking up decisions

Re-fetch any decision by ID. ETag-cacheable. Optionally enrich with signals + transaction.

GET/api/v1/decisions/{public_id}decisions:read

The same shape /evaluate returns, addressable by the decision_id from the original evaluation. Useful when:

  • You need to display a historical decision in your operations console.
  • A retry or replay needs the latest server-side view (e.g. an analyst overrode the original decision).
  • Your settlement layer wants to confirm the platform's view before releasing funds.

Request

cURL
curl https://staging.kupinga.net/api/v1/decisions/7c4d2a3b-5e6f-7a8b-9c0d-1e2f3a4b5c6d \
  -H "Authorization: Bearer $API_KEY"

The path parameter is the decision_id (a UUID) from the /evaluate response. The transaction's own transaction_id is NOT accepted here — use decision_id.

Response — DecisionDetailResponse

The base shape is identical to DecisionResponse.

JSON
{
  "transaction_id": "uuid",
  "decision_id":   "uuid",
  "outcome":       "approve|decline|challenge",
  "risk_score":    0..100,
  "reason_codes":  [],
  "recommended_actions": [],
  "challenge":     null,
  "processing_time_ms": 87,
  "request_id":    "uuid",

  // Present only when ?include=signals
  "signals": null,

  // Present only when ?include=transaction
  "transaction": null
}

processing_time_ms reflects the original evaluation. Reads don't re-evaluate, so the field is preserved from the persisted decision row.

?include= — pulling more

cURL
curl "https://staging.kupinga.net/api/v1/decisions/$DECISION_ID?include=signals,transaction" \
  -H "Authorization: Bearer $API_KEY"
IncludeAdds fieldMeaning
signalssignalsThe per-rule signal trail. Each entry shows which rule fired, what score it contributed, and the snapshot of values that made it match. Useful for explaining a decline to your compliance team.
transactiontransactionThe full persisted transaction row. Heavier payload; ask for it only when you need to display the full context.

Comma-separate multiple values. Unknown values are silently ignored, so ?include=signals,future-block is forward-compatible.

When an include is requested: signals: [] means we looked and found no signals (rare but possible — a rules-only path with no hits). signals: null means you didn't ask for it. The pointer-to-slice trick on the wire is intentional: the empty array is a meaningful answer, distinct from "not requested."

ETag / If-None-Match caching

Decisions are immutable once written. The endpoint stamps a strong ETag derived from (decision_id, evaluated_at, include-set) and sends Cache-Control: private, max-age=30. Both the ETag and the cache window are stable across requests for the same logical resource.

Bash
# First call — gets 200 + ETag
ETAG=$(curl -i https://staging.kupinga.net/api/v1/decisions/$DECISION_ID \
  -H "Authorization: Bearer $API_KEY" \
  | awk '/^ETag:/{print $2}' | tr -d '\r')

# Second call — gets 304 with no body
curl -i https://staging.kupinga.net/api/v1/decisions/$DECISION_ID \
  -H "Authorization: Bearer $API_KEY" \
  -H "If-None-Match: $ETAG"

304 Not Modified carries ETag and Cache-Control but no body. The If-None-Match: * wildcard is also accepted and short-circuits to 304 whenever the resource exists.

When to look up vs. trust the inline response

SituationWhat to do
Inside the request/response cycle of /evaluate and need to act on the outcome.Trust the inline response. Don't re-fetch.
Your settlement layer runs minutes or hours after the original /evaluate.Re-fetch via /decisions/{id}. An analyst may have overridden.
Displaying the decision in an internal console.Re-fetch. The persisted row is the source of truth.
Need the rule trail for compliance review.Re-fetch with ?include=signals.

Failure modes

StatusCodeMeaning
400invalid_idThe path parameter isn't a parseable UUID.
403forbiddenYour credential is valid but doesn't carry decisions:read.
404not_foundNo decision with that public_id. Final — don't retry in a loop.
500internal_errorPlatform-side error. Retry with exponential backoff.

Customers

Read-mostly surface with a small write path for explicit CIF onboarding.

POST/api/v1/customerscustomers:write
PUT/api/v1/customers/{public_id}customers:write
GET/api/v1/customers?q=<search-term>customers:read
GET/api/v1/customers/{public_id}customers:read
GET/api/v1/customers/{public_id}/accountscustomers:read
GET/api/v1/customers/by-bvn-hash/<64-hex>customers:read

If your integration only fires /evaluate and never explicitly onboards customers, the platform creates a stub customer row on first sighting (customer_id carried in the transaction). For explicit onboarding — CIF provisioning, branch-side enrolment, back-office corrections — use the write path below.

Hashing PII before sending

BVN, NIN, and passport numbers never leave your perimeter in the clear. Hash them with HMAC-SHA256 using the shared pepper from your onboarding pack and send the 64-char lowercase hex digest:

Hash a BVN
import hmac, hashlib
PEPPER = b"<shared-pepper-from-onboarding-pack>"

def hash_id(value: str) -> str:
    return hmac.new(PEPPER, value.encode(), hashlib.sha256).hexdigest()

hash_id("22345678901")       # → 'a3f9...' (64 hex chars)

The platform validates every *_hash field is exactly 64 hex characters and rejects anything else with 400 validation_error. If your hashes don't match the platform's, screening hits and linkage will silently miss — verify a known test BVN end-to-end during the integration smoke.

Create — POST /customers

cURL
curl -X POST https://staging.kupinga.net/api/v1/customers \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_number":  "CUST-0001",
    "first_name":       "Adaeze",
    "last_name":        "Eze",
    "email":            "adaeze@example.com",
    "phone":            "+2348012345678",
    "date_of_birth":    "1990-04-12",
    "gender":           "F",
    "bvn_hash":         "a3f9...64-hex...",
    "nin_hash":         "b1c8...64-hex...",
    "city":             "Lagos",
    "state":            "Lagos",
    "country":          "NGA",
    "nationality":      "NG",
    "kyc_tier":         "tier_2",
    "is_pep":           false
  }'
FieldRequiredNotes
customer_numberyesYour CIF identifier, max 64 chars. Unique.
first_name, last_nameyesPlain UTF-8.
emailyesRFC-5322 validated.
phoneyesE.164. Validated.
date_of_birthnoISO date YYYY-MM-DD.
gendernoM, F, or X.
bvn_hash, nin_hashno¹HMAC-SHA256, 64-hex.
passport_hash, drivers_license_hashno¹SHA-256/HMAC-SHA256, 64-hex. Used for screening grade on foreign nationals lacking BVN/NIN.
passport_expires_at, drivers_license_expires_atno²ISO date. Drives expiry notifications.
address_line1, address_line2noFree-form, max 200.
city, state, postal_codenoFree-form.
countryyesISO-3 country code (NGA, GBR, USA).
nationalitynoISO-2 country code (NG, GB, US).
place_of_birth, business_namenoFree-form. Set business_name for corporate customers.
kyc_tieryestier_1, tier_2, tier_3.
is_pepnoDefaults false.

¹ At least one identifier (BVN, NIN, passport, driving licence hash) is strongly recommended — without it the screening grade and graph linkage have nothing to anchor on.

² Expiry dates are optional. When set, the platform's daily credential-expiry sweeper finds customers whose IDs expire within 30 days (or have already expired) and writes a customer.credential_expiring notification to the customer's in-app inbox. Idempotent — re-runs don't produce duplicates.

Response (201)

JSON
{
  "id":              "5d8c9b0a-1234-5678-90ab-cdef01234567",
  "customer_number": "CUST-0001",
  "first_name":      "Adaeze",
  "last_name":       "Eze",
  "email":           "adaeze@example.com",
  "phone":           "+2348012345678",
  "bvn_hash":        "a3f9XX...XXXX",
  "nin_hash":        "b1c8XX...XXXX",
  "passport_hash":   null,
  "country":         "NGA",
  "nationality":     "NG",
  "kyc_tier":        "tier_2",
  "status":          "active",
  "is_pep":          false,
  "risk_score":      0,
  "risk_tier":       "low",
  "screening_status":"pending",
  "created_at":      "2026-05-25T08:42:13.041Z",
  "updated_at":      "2026-05-25T08:42:13.041Z"
}

*_hash fields come back masked (first 6 + last 4 hex) — the raw 64-hex digest is never echoed once stored.

Update — PUT /customers/{public_id}

Partial update — send only the fields you want to change.

Mutable: email, phone, first_name, last_name, address_line1, address_line2, city, state, postal_code, country, kyc_tier, is_pep, risk_score, risk_tier, status, passport_hash, nationality, place_of_birth, business_name.

Immutable post-creation: customer_number, bvn_hash, nin_hash, date_of_birth, gender. If any of these need correction (e.g. wrong BVN attached at onboarding), open a support ticket — the platform requires a maker-checker audit trail.

cURL
curl "https://staging.kupinga.net/api/v1/customers?q=ada" \
  -H "Authorization: Bearer $API_KEY"

Required q parameter — free text (name, email, customer number, BVN hash). Empty q returns 400 missing_query. Response: a JSON array of CustomerResponse, capped at 20. Matches name fragments, email prefix, customer number, and hashed BVN. Does NOT search free-text fields like address or narration.

Get accounts

JSON
{
  "accounts": [
    {
      "id": "...",
      "nuban": "0123456789",
      "bank": { "cbn_code": "044", "short_name": "Access", "name": "Access Bank Plc" },
      "account_type": "savings",
      "status": "active",
      "pnd": false,
      "pnd_effective_to": null,
      "balance": null,
      "opened_at": "2025-11-14T08:00:00Z",
      "branch_code": "0001",
      "branch_name": "Victoria Island"
    }
  ]
}

balance is always null today. pnd is the Post-No-Debit flag (regulatory hold); true means the account is frozen until pnd_effective_to or manually lifted by compliance.

BVN-hash lookup

The path parameter must be exactly 64 lowercase hex chars. A non-conforming value returns 400 invalid_bvn_hash before the lookup runs — the URL is logged at multiple layers, so the strict shape check guards against accidentally putting a raw BVN in the path.

Credential expiry notifications

For customers who have passport_expires_at or drivers_license_expires_at on file, the platform runs a daily sweeper at 08:00 UTC. For each active customer whose expiry falls within 30 days (or is already in the past), it writes a customer.credential_expiring notification to the customer's in-app inbox.

The notification includes: the customer's first name, the document type ("passport" / "driver licence"), a masked document label (e.g. "passport ending in …X201"), the expiry date, and whether the date is past or future. Idempotent — re-runs on the same day produce zero duplicate notifications. When a customer renews and the integration updates the expiry via PUT /customers/{id}, the new date changes the idempotency key and the sweeper fires one more notification when the new date approaches.

Scenario catalogue entry: FRD-038 ("Customer identity document expiring or expired") with default_action = alert, default_priority = medium, default_score = 25. In-app only today; email mirroring available per-tenant on request.

Corporate customers need a control-structure record: directors, shareholders, beneficial owners, signatories, secretaries, trustees. The platform models all of these as one polymorphic related party plus a corporate relationship that pins the party to a customer with a specific role and effective dates.

GET/api/v1/customers/{public_id}/related-partiescustomers:read
POST/api/v1/customers/{public_id}/related-partiescustomers:write
PATCH/api/v1/customers/{public_id}/related-parties/{rp_public_id}customers:write
POST/api/v1/customers/{public_id}/related-parties/{rp_public_id}/relationshipscustomers:write
DELETE/api/v1/customers/{public_id}/related-parties/{rp_public_id}/relationships/{rel_public_id}customers:write

Supported roles: director, shareholder, beneficial_owner, signatory, secretary, trustee. A single related party can hold multiple roles on the same customer — register the party once, then add each role via the /relationships sub-route.

cURL
curl -X POST https://staging.kupinga.net/api/v1/customers/{public_id}/related-parties \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "full_name":           "Olamide Tijani",
    "date_of_birth":       "1978-03-22",
    "nationality":         "NG",
    "country_of_residence":"NG",
    "bvn_hash":            "a3f9...64-hex...",
    "nin_hash":            "b1c8...64-hex...",
    "id_type":             "passport",
    "id_number_hash":      "c2d4...64-hex...",
    "role":                "director",
    "share_pct":           25.00,
    "source":              "cac_registry",
    "effective_from":      "2024-01-15"
  }'

The platform writes the party row and the opening corporate-relationship row in one transaction — you never get a half-created party. PEP status is derived state: don't try to set is_pep on create; it's maintained by the screening pipeline via pep_match_id.

Mutable on PATCH: full_name, date_of_birth, nationality, country_of_residence. Immutable post-create: bvn_hash, nin_hash, id_type, id_number_hash, is_pep. Sent values for immutable fields are silently ignored.

Adding another role: POST /…/{rp_public_id}/relationships. 409 conflict if a relationship row already exists for the same (customer, party, role, effective_from) tuple — use a different effective_from (typically one day later) to record a real change.

Retiring: DELETE /…/relationships/{rel_public_id}. 204 No Content. The platform sets effective_to = now(). Row stays for audit; current-roles queries filter on effective_to IS NULL. 404 if already retired — you can't retire the same relationship twice.

Tenant boundary Every mutating call resolves the relationship between the URL's customer and the URL's party. If the party isn't actually linked to that customer — neither via a direct foreign key nor via any corporate-relationship row — the platform returns 404 not_found. A caller with customers:write for one customer cannot mutate another customer's directors / BOs by guessing UUIDs.

Customer 360 — internal surface

GET /api/v1/customers/{public_id}/360 returns a heavyweight aggregate used by the platform's own admin UI. Client integrations should not consume it directly — the shape is tuned for the React console and may change without notice. If you need a richer view than GET /customers/{id} gives you, ask support to either add fields to the canonical endpoint or expose a dedicated path.

Failure modes

StatusCodeMeaning
400missing_queryq parameter missing on search.
400invalid_idpublic_id not a UUID.
400invalid_bvn_hashBVN-hash path parameter isn't 64 hex chars.
400validation_errorBody validation failed; fields[] names each.
404not_foundNo customer matches.
409duplicate_customerDuplicate customer_number on create.

Webhooks

Push events to your endpoint instead of polling. Signed HMAC-SHA256, retried with backoff, replay-protected.

POST/admin/webhookswebhooks:write
GET/admin/webhookswebhooks:read
GET/admin/webhooks/{id}webhooks:read
PATCH/admin/webhooks/{id}webhooks:write
DELETE/admin/webhooks/{id}webhooks:write
GET/admin/webhooks/{id}/healthwebhooks:read
GET/admin/webhooks/{id}/deliverieswebhooks:read
Current state Webhook management is admin-permissioned only. Self-service for clients is on the roadmap; for now, request a webhook subscription by emailing integration@<your-domain> with: target URL, the events you want to subscribe to, the destination IP / CIDR range your target_url resolves to, and the contract reference. The platform admin will provision the subscription on your behalf and seed the HMAC secret in Vault.

Event catalogue

EventFires whenPayload anchor
decision.created/evaluate persists a decision (every outcome).decision_id + transaction_id
alert.createdA new alert lands in the analyst inbox.alert_id + customer_id
case.createdAn alert escalates to a case.case_id + alert_ids[]
case.resolvedA case closes with a terminal status.case_id + resolution

Subscribing to an unknown event name returns 400 invalid_event at create time. The internal Kafka topics carry richer event types (transaction.completed.v1, cases.status_changed.v1, etc.); the webhook surface deliberately exposes only the subset that's safe to publish to external systems.

Creating a subscription

cURL
curl -X POST https://staging.kupinga.net/admin/webhooks \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "bank-alpha-case-notifications",
    "description": "Pipes case.created + case.resolved into the bank ops inbox.",
    "target_url": "https://hooks.bank-alpha.example/antifraud",
    "secret_ref": "secret/clients/bank_alpha/webhook_v1",
    "events": ["case.created", "case.resolved"],
    "headers": { "X-Bank-Realm": "production" },
    "timeout_ms": 5000,
    "is_active": true,
    "consecutive_failures_max": 10,
    "ip_allowlist": ["203.0.113.0/24", "2001:db8:abcd::/48"]
  }'
FieldMeaningEdit path
nameUnique label within your scope. Lowercase + dashes recommended.routine PATCH
descriptionFree-form. Surfaces in the admin console.routine PATCH
target_urlHTTPS URL. HTTP is refused at create time. Subject to the SSRF guard + destination IP allowlist.maker-checker
secret_refVault path the platform reads the HMAC signing key from. Operator seeds the value during onboarding.maker-checker
eventsArray of event names. Must be in the allowlist.maker-checker
headersOptional static headers added to every delivery.routine PATCH
timeout_msPer-attempt HTTP timeout. Default 5,000. Range 500–30,000.routine PATCH
is_activeSet true to start receiving deliveries immediately. false to provision-then-flip.maker-checker
statusactive / suspended / retired. Auto-suspension is driven by consecutive_failures.maker-checker
ip_allowlistOptional INET[] of CIDRs / bare IPs the resolved destination must fall inside.maker-checker
consecutive_failures_maxAfter this many consecutive failures, the platform auto-suspends the subscription and emails on-call. Default 10.routine PATCH

Delivery shape

The platform POSTs JSON to your target_url with these headers:

HeaderMeaning
Content-Typeapplication/json
X-Event-TypeEvent type, e.g. case.created.
X-Event-IdUUID of the event. Use this for consumer-side deduplication.
X-Delivery-IdUUID of THIS delivery attempt. Same event_id may carry different delivery_id across retries.
X-SignatureHMAC-SHA256 over the body, hex-encoded, prefixed sha256=.
User-Agentantifraud-webhooks/<version>

There is NO X-Timestamp header — the send-time timestamp lives in the body's event_timestamp field instead. Read it from the JSON, parse RFC 3339, reject deliveries older than ~5 minutes (replay defence).

Body (decision.created example)
{
  "event_id": "uuid",
  "event_type": "decision.created",
  "event_timestamp": "2026-05-25T11:42:13.041Z",
  "data": {
    "decision_id": "uuid",
    "transaction_id": "uuid",
    "outcome": "decline",
    "risk_score": 87,
    "reason_codes": ["SANCTIONS_HIT"],
    "merchant_id": "BANK_ALPHA_NG"
  }
}

The data block shape varies per event type. Treat unknown fields as forward-compatible additions; don't reject the payload because of an unexpected key.

Verifying the signature

The HMAC key is whatever the platform admin seeded in Vault at your secret_ref path. They'll share it with you out-of-band during onboarding.

Python
import hmac, hashlib

def verify(body: bytes, signature_header: str, secret: bytes) -> bool:
    # signature_header looks like "sha256=abcd1234..."
    if not signature_header.startswith("sha256="):
        return False
    expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
    received = signature_header[len("sha256="):]
    return hmac.compare_digest(expected, received)
Always use constant-time comparison hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js. A naive == comparison leaks the signature byte-by-byte under timing attack.

Reject deliveries that fail signature verification (respond 401), carry a body event_timestamp more than 5 minutes in the past (respond 400), or carry an X-Event-Id you've already processed (respond 200 — idempotency).

IP allowlisting & SSRF guard

The dispatcher enforces two layers of destination control before any event leaves the platform.

Unconditional SSRF guard

Every delivery has its target_url resolved via DNS at dispatch time. If any resolved IP falls in one of the following ranges, the delivery is blocked, the row is marked abandoned with error_class='destination_blocked', and the subscription's consecutive_failures counter increments toward auto-suspend.

RangeWhy it's blocked
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16RFC1918 — internal infra (PG, Redis, sibling units).
127.0.0.0/8, ::1/128Loopback — self-targeting from the dispatcher host.
169.254.0.0/16Link-local — covers cloud instance metadata services (IMDS at 169.254.169.254).
fc00::/7IPv6 unique-local — IPv6 equivalent of RFC1918.
fe80::/10IPv6 link-local — IPv6 IMDS / on-link only.

The block is unconditional in production. There is no per-subscription override; a knob exists for single-VM development (webhooks.allow_private_destinations) but flipping it in production emits a WARN log on every dispatcher startup.

Per-subscription destination allowlist

The optional ip_allowlist field pins the resolved destination to a fixed set of IPs / CIDRs. CIDR or bare IPs, IPv4 + IPv6. Max 50 entries. Empty ([]) means "any resolved destination IP", still subject to the SSRF guard. Enforcement gated by WEBHOOKS_ENFORCE_DESTINATION_ALLOWLIST.

error_reasonCause
ssrf_privateA resolved IP fell in the unconditional block list above.
not_in_allowlistA resolved IP fell outside ip_allowlist.
dns_failureThe hostname did not resolve. Fail-closed by design.

Maker-checker on restricted fields

Six fields can redirect or unlock the signed event stream and are governed by a two-person rule: target_url, secret_ref, events, is_active, status, ip_allowlist. A single-admin PATCH that touches any of these returns 409 requires_proposal. Edits flow through propose + approve:

cURL
# Maker
curl -X POST https://staging.kupinga.net/admin/webhooks/{id}/changes \
  -H "Authorization: Bearer $MAKER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "field_changes": {
      "target_url": "https://hooks.bank-alpha.example/antifraud-v2",
      "ip_allowlist": ["203.0.113.0/24"]
    },
    "reason": "Rolling target to the v2 endpoint"
  }'

# Checker (must be a different user)
curl -X POST https://staging.kupinga.net/admin/webhooks/changes/{change_id}/approve \
  -H "Authorization: Bearer $CHECKER_TOKEN"

Proposer ≠ approver is enforced in SQL, not just in application code. Proposals expire after 7 days. A new proposal for the same subscription supersedes the prior pending one. Routine fields (name, description, headers, timeout_ms, consecutive_failures_max) continue via direct PATCH.

Retry policy

When your endpoint returns non-2xx or the request times out, the platform retries with exponential backoff:

AttemptWait before retry
1(initial delivery)
230 seconds
35 minutes
430 minutes
52 hours

After 5 attempts the delivery is marked abandoned. If the subscription's consecutive_failures count reaches consecutive_failures_max, the whole subscription auto-suspends and the platform's on-call gets paged. To resurrect, file a status change proposal (maker-checker).

Consumer-side idempotency

The same event_id may be delivered more than once (auto-retry, manual replay, network duplication). Dedupe on event_id before acting on the payload. A reasonable consumer pattern: maintain a fast-lookup table of event_ids you've processed in the last 24 hours. On every incoming delivery, INSERT … ON CONFLICT DO NOTHING; if the conflict fires, return 200 without re-processing.

Listing + inspecting deliveries

cURL
# List your subscriptions
curl https://staging.kupinga.net/admin/webhooks \
  -H "Authorization: Bearer $ACCESS_TOKEN"

# Last 24h health summary
curl https://staging.kupinga.net/admin/webhooks/{id}/health \
  -H "Authorization: Bearer $ACCESS_TOKEN"

# Recent deliveries (default 50, max 500)
curl "https://staging.kupinga.net/admin/webhooks/{id}/deliveries?limit=100" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

/health returns counts (delivered, failed, abandoned, in_flight) plus the last delivered timestamp and the last error. /deliveries returns per-row attempt history. DELETE /admin/webhooks/{id} soft-retires (status flips to retired; row stays for audit; new events stop flowing immediately).

Rate limits & errors

The cross-cutting reliability contract: how fast you can call, what errors look like, and how to retry.

Rate-limit tiers

Per-API-key, sliding-window. Set at mint time via the tier field. Beyond the per-key tier, each /evaluate/{channel} route has its own per-caller bucket — see channel limits.

TierSustained per minuteBurstTypical fit
starter10020Smoke test / POC
standard1,00050Single-branch bank, mid-sized PSP
premium10,000500Multi-channel bank, large PSP
unlimitedno throttleNegotiated (tier-1 banks with contractual exemption)

Burst is the additional headroom above the sustained rate when the bucket is full. A standard key can burst 50 calls above the 1,000/min ceiling before the limiter kicks in.

Rate-limit headers

Every response (allowed or denied) carries:

HeaderMeaning
X-RateLimit-LimitTier limit + burst, i.e. the bucket size.
X-RateLimit-RemainingHow many calls remain in the current window.
X-RateLimit-ResetUnix timestamp (seconds) when the bucket next refills.
Retry-After(On denial only) Seconds to wait before retrying. Always ≥ 1.
Python
import time, requests

def call_with_backoff(session, *args, **kwargs):
    for attempt in range(5):
        resp = session.request(*args, **kwargs)
        if resp.status_code != 429:
            return resp
        delay = int(resp.headers.get("Retry-After", 1))
        time.sleep(delay)
    return resp  # last response, surface to caller

Error envelope

Every error response carries the same shape:

JSON
{
  "error": {
    "code": "snake_case_machine_token",
    "message": "Human-readable description, safe to log.",
    "details": [
      {
        "field": "amount",
        "code": "gte",
        "message": "amount must be >= 0",
        "param": "0"
      }
    ]
  },
  "request_id": "uuid"
}

details is present on validation errors (422) and absent on plain errors. request_id matches the chi request ID and the X-Request-Id header. Always quote it on a support thread.

Error catalogue

400 — Bad request

CodeMeaningRetryable?
invalid_jsonRequest body wasn't parseable JSON.No. Fix the body.
invalid_bodyBody couldn't be read at all.No.
invalid_inputRequest parsed but a field is malformed.No.
invalid_idPath UUID is malformed.No.
invalid_bvn_hashBVN-hash path parameter isn't 64 hex chars.No.
invalid_tierUnknown API-key tier on a key-mint request.No.
unknown_scopeScope not in the catalogue on a key-mint request.No.
missing_queryRequired query parameter missing.No.
invalid_eventWebhook event name not in the allowlist.No.
missing_refresh_token/auth/refresh called without refresh_token.No.

401 — Unauthorized

CodeMeaningRetryable?
missing_authenticationNo Authorization header.No. Add the header.
invalid_credentialsBearer present, didn't verify.No. Mint a fresh key / log in again.
token_expiredJWT access token past its 15-minute lifetime.Yes after /auth/refresh.
token_revokedRefresh token replay.No. Force re-login.

403 — Forbidden

CodeMeaningRetryable?
forbiddenAuthenticated but scope is insufficient.No. Request the missing scope from your admin.

404 — Not found

CodeMeaningRetryable?
not_foundResource doesn't exist (decision, customer, webhook).No.
UNKNOWN_BANKBank code not in the registry.No. Fix the bank code.
UNKNOWN_ATMATM ID not in the registry.No. Fix the atm_id.

(UNKNOWN_BANK / UNKNOWN_ATM use uppercase because they're business error codes vs. transport error codes — both shapes coexist.)

409 — Conflict

CodeMeaningRetryable?
duplicate_transaction(merchant_id, external_id) already exists. Response body carries the cached decision.No — the answer is in the body.
idempotency_conflictSame X-Idempotency-Key, different body.No. Mint a fresh key.
idempotency_in_flightPrior request with the same key hasn't finished.Yes after a short backoff (200–500 ms).
conflictGeneric create-time collision (e.g. webhook name reuse).No.

422 — Unprocessable entity

CodeMeaningRetryable?
validation_errorWire-shape problem. details[] lists each offending field with the failing rule.No — fix the body.

The validator runs in layered passes (single-field shape → channel-payload alignment → semantic checks like NUBAN check digit). Errors from later passes only surface once earlier passes clear. Fix the first error in details[], retry; the platform may then surface a deeper error.

429 — Too many requests

CodeMeaningRetryable?
rate_limit_exceededPer-key tier or per-channel bucket exhausted.Yes after Retry-After.

500 / 503 — Server

CodeMeaningRetryable?
internal_errorPlatform-side fault.Yes with exponential backoff (1s, 2s, 4s, 8s, 16s, give up).
service_unavailablePlatform overload or upstream outage.Yes with exponential backoff.

When one of the dependencies in the synchronous path is degraded (ML scoring offline, screening offline, etc.) the platform may fall back to a rules-only decision path. The synchronous /evaluate response still lands as 200 with the rules-only outcome — the platform doesn't 503 the route just because a non-critical enricher is down. A genuine 503 means the route itself can't serve the request.

Retry strategy

A defensive retry policy you can drop into any client:

ConditionStrategy
200, 201, 204Don't retry. Done.
304Don't retry. The body you have is current.
400 / 401 / 403 / 404 / 422Don't retry. Fix the request.
409 duplicate_transactionDon't retry. The body carries the answer.
409 idempotency_in_flightRetry after 200 ms, then 500 ms, then give up.
429Retry after Retry-After seconds.
500 / 502 / 503 / 504Retry with exponential backoff: 1s, 2s, 4s, 8s, 16s. Give up after 5 attempts.
Network error (DNS, connection refused, TLS handshake fail, timeout)Same as 5xx.

Always cap retries at 5 attempts. Beyond that, surface the failure to your callers — the alternative is a stuck queue that hides a real outage. When retrying with the same X-Idempotency-Key, the platform's idempotency layer guarantees you'll either get the same response again (replay) or 409 if the body differs.

Connection management

  • Use TLS 1.2 minimum, 1.3 preferred. Modern AEAD ciphers; HSTS pinned.
  • Reuse HTTP/2 connections aggressively. One connection can serve hundreds of concurrent requests.
  • Set a client-side timeout of ~2 seconds per call. The platform's internal budget caps at 1.5s; anything longer is a network problem.
  • If you sit behind a corporate proxy, confirm it doesn't strip the Authorization header. Some proxies do this by default.

Latency expectations

Endpointp50 budgetp99 budget
POST /evaluate300 ms500 ms
GET /decisions/{id}25 ms100 ms
GET /customers?q=...50 ms200 ms
GET /customers/{id}25 ms100 ms
POST /auth/login200 ms500 ms (bcrypt is intentionally slow)
POST /auth/refresh25 ms100 ms
POST /api-keys50 ms150 ms
Webhook delivery (platform → client)n/a (async)timeout per subscription (default 5s)

Going to production

The promotion checklist. When all items below are green, the integration is ready for production cutover.

Pre-flight on staging

Run these against https://staging.kupinga.net first. They are non-negotiable; staging is where you find the bugs.

Functional

  • Full Postman collection passes top-to-bottom in Runner. Import from postman/.
  • Login + API-key mint + revoke flow works.
  • All channels you'll send in production have at least one worked example green: POS, ATM, USSD, NIP, etc.
  • Customer search returns expected results for known test customers.
  • Decision lookup returns the same outcome you saw inline on /evaluate.
  • Decision lookup with ?include=signals,transaction returns enriched data without 5xx.
  • If-None-Match revalidation lands a 304 on the second call.

Idempotency

  • Same X-Idempotency-Key + same body → 200 with X-Idempotent-Replay: true on retry.
  • Same X-Idempotency-Key + different body → 409 idempotency_conflict.
  • Same (merchant_id, external_id), no idempotency key → 409 duplicate_transaction with the cached decision in the body.
  • Network-disconnect mid-flight scenario: the platform's self-heal logic recovers and the retry lands as a replay, not a duplicate. Test by killing the TCP connection between sending the request and reading the response.

Webhooks (when subscribed)

  • Signature verification works against your verifier. Include a deliberate signature-mismatch case to confirm your endpoint rejects it.
  • Replay protection: an event with an event_timestamp more than 5 minutes in the past is rejected.
  • Consumer-side idempotency: the same X-Event-Id delivered twice causes exactly one downstream effect.
  • Retry policy validates: deliberately 500 your endpoint and observe the platform retry with the documented backoff.

Capacity

  • Sustained load at 90% of your contracted tier RPS for 5 minutes. The platform side handles this; the test confirms your client side can too.
  • No 429 responses during the steady-state window. If you see any, you're either under-provisioned or hot-spotting on a per-channel bucket — investigate before going live.
  • Tail latency: p99 stays inside 500ms for /evaluate throughout the run.
  • Webhook deliveries don't queue up: GET /admin/webhooks/{id}/health shows in_flight returning to 0 within seconds of the load test ending.

Error handling

  • Your client retries 429 with Retry-After.
  • Your client retries 5xx and network errors with exponential backoff, capped at 5 attempts.
  • Your client does NOT retry 4xx errors other than 429 and 409 idempotency_in_flight.
  • Your client logs request_id on every failed call.
  • PII never lands in your client-side logs (BVN, NIN, raw PAN). Mask before logging.

Security

  • API key is in your secret manager, not in source control.
  • No staging keys in production binaries (and vice versa).
  • TLS 1.2+ enforced on the client side.
  • Bearer tokens never appear in URL paths or query strings (only headers).
  • HMAC signing on webhooks uses constant-time comparison.

Production tier negotiation

Before promotion, confirm in writing with integration@<your-domain>:

  • Production tier (starter / standard / premium / unlimited)
  • Production source IP range (for allowlisting on the API key)
  • Production webhook target URL (different from staging)
  • Production go-live date
  • Production on-call rotation contact for the first 72 hours

The production tier is what the API key gets minted with. Once production traffic starts, lifting the tier is fast (a new key + rotation); lowering it is fast too (PATCH the key).

Cutover sequence

The cutover is a coordinated event. The platform side handles:

  1. Tenant cloned to production. A new merchant_id provisioned under the production cluster, same shape as staging.
  2. Production API key minted. Same scopes as staging, the contracted tier, and an allowed_cidrs list populated from your IP range. Delivered out-of-band. Later changes to the allowlist require a two-person maker-checker approval.
  3. Production webhook subscription created (when contracted), with a fresh HMAC secret in production Vault. Staging secret never reused.
  4. Runbook handoff. Platform on-call shares the operator runbook excerpt covering: how to escalate, how to identify merchant traffic in our dashboards, how to read our latency + error dashboards.

You handle:

  1. Flip the API key in your secret manager to the production value.
  2. Flip the base URL in your client config to the production host.
  3. Flip your webhook endpoint to the production handler.
  4. Smoke test the production tenant with a low-value transaction (e.g. ₦100 internal test). Confirm the decision lands and the webhook fires.
  5. Cut over real traffic in a measured ramp: 1% → 10% → 50% → 100% over the first hour.

First 72 hours

The platform side runs heightened monitoring for the first three days post-cutover:

  • Decision-outcome ratio drift watched against staging baseline. A sudden swing (e.g. decline rate from 0.5% to 15%) gets an immediate on-call page.
  • Webhook delivery latency + retry counts on your subscription.
  • Per-channel rate-limit utilisation.
  • p99 latency on /evaluate for your merchant_id.

You should watch your decline rate, 429s, and webhook consumer lag. If anything diverges from staging, file a support ticket with sample request_ids.

When to call

SituationChannelSLA
Production incident (decline storm, 5xx burst, latency spike)oncall@<your-domain>15-minute response
Functional defect in production (a specific transaction decided wrong)integration@<your-domain>Next business day
Tier upgrade / additional scope requestintegration@<your-domain>2 business days
Webhook reconfigurationintegration@<your-domain>1 business day
Maintenance announcementsstatus.kupinga.com (now live)n/a

Always quote merchant_id and request_id (or decision_id, event_id, webhook_id). The platform indexes everything on these — an investigation starting from them resolves in minutes; one without them can take hours.

Postman & SDKs

Importable collections, environment files, and snippet packs.

Postman collection

Open Postman and import three files from postman/:

  1. antifraud-rest-api.postman_collection.json
  2. antifraud-staging.postman_environment.json
  3. antifraud-production.postman_environment.json

Select the Antifraud — staging environment, run the Auth → Login request first, then API Keys → Create. The collection's test scripts automatically populate {{accessToken}} and {{apiKey}} into the environment so the rest of the requests work without manual copy-paste.

The Collection Runner (Postman → Collections → Run) executes the full suite top-to-bottom and prints green checks against the response assertions baked into each request's Tests tab.

Snippet packs

The examples/ folder ships curl, Python, and Node.js snippets for the happy-path flows. They mirror the worked examples in this documentation but are written to compile and run unchanged against a fresh API key.

Environment matrix

EnvironmentBase URLTierIP allowlist enforced?
Staginghttps://staging.kupinga.netstandardNo (audited)
Production(per-tenant, emailed at cutover)per contractYes

Support

Where to go when things go sideways.

ChannelUse for
integration@<your-domain>Integration questions, request a webhook subscription, request a tier upgrade.
oncall@<your-domain>Production incident only (decline-rate spike, 5xx storm, latency regression).
/statusLive operational status. Auto-refreshes every 30 seconds.

Quote your merchant_id and (where available) the request_id from the response on every support thread. The platform indexes every audit + log line on those two fields; an investigation starting from them resolves in minutes; one without them can take hours.

That's the package The next time you'd come back to this site is when you're integrating a second region, a new channel, or rotating keys. The structure stays the same; only the per-channel detail in /evaluate grows.
Kupinga

Kupinga · Real-time anti-fraud, enterprise-grade.

REST API v1 · Documentation last issued 2026-05-28

kupinga.net · User manual · FAQ