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.
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/challengedecision 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-Matchfor 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, andalert.createdwith HMAC signing and automatic retry.
5-line quickstart
The fastest path from zero to a live decision against staging:
# 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
Login & tokens
JWT for interactive testing. API key for production server-to-server.
KeysAPI key management
Mint, scope, allowlist by CIDR, rotate, and revoke.
Score/evaluate
The endpoint your integration spends most of its time on. Per-channel routes for POS, ATM, USSD, mobile, NIP.
Read/decisions/{id}
Re-fetch a decision with optional signals + transaction includes. ETag caching.
CRM/customers
Search, register, update, look up by BVN hash, manage related parties.
Events/admin/webhooks
Subscribe to decisions, alerts, and cases. HMAC-SHA256 signed. Retry policy + replay protection.
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
Low-risk. Settle / authorise normally.
review
Suspicious. Approve, then queue for analyst review.
challenge
Step-up required. Drive the customer through OTP / biometric / PIN before settling.
decline
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.
The deliverable
The operator hands you a single email containing:
- The staging base URL:
https://staging.kupinga.net - A maker username + initial password
- A checker username + initial password
- The merchant identifier you'll send on every transaction
- The list of feature flags / scopes you're contracted for
- 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/evaluatecall.- 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 feature | Permission grant |
|---|---|
| Transaction evaluation | transactions:write (analyst tier inherits) |
| Decision lookup | decisions:read (analyst tier inherits) |
| API key management | api_keys:write (must add explicitly) |
| Customer 360 lookup | customers:read (analyst tier inherits) |
| Webhook configuration | webhooks: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_cidrspopulated (IPv4 + IPv6, up to 50 entries, CIDR or bare IP). - Document the range in the runbook.
- Confirm
API_KEYS_ENFORCE_ALLOWLIST=trueon 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/webhookswithstatus: draft, the client's target URL, and asecret_refpointing to a Vault path.- Seed the HMAC secret in Vault at the
secret_refpath.
You then PATCH /admin/webhooks/{id} with status: active once your endpoint is verified.
Step 6 — welcome email
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:
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.
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 -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):
{
"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 -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
| Field | Type | Required | Meaning |
|---|---|---|---|
| name | string | yes | Human-readable label. Appears in the admin console + audit logs. |
| merchant_id | string | yes | Must match the merchant_id assigned in tenant setup. Rate-limit + audit story keys off this. |
| tier | string | no | One of starter, standard, premium, unlimited. Defaults standard. |
| scopes | string[] | recommended | The permission names this key can exercise. See Scopes below. |
| expires_at | RFC 3339 | no | When the key auto-revokes. Omit for "never" (use sparingly). 1 year is a reasonable default. |
| allowed_cidrs | string[] | no | Source-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
{
"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"
}
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.
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 string | What it unlocks |
|---|---|
| evaluate | POST /api/v1/evaluate and all /api/v1/evaluate/{channel} per-channel routes. |
| decisions:read | GET /api/v1/decisions/{id}. |
| customers:read | GET /api/v1/customers (search), /customers/{id}, /customers/{id}/accounts. |
| webhooks:read | GET /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
| Tier | Sustained (per minute) | Burst | Typical fit |
|---|---|---|---|
| starter | 100 (~1.7/s) | 20 | Smoke test / POC |
| standard | 1,000 (~16.7/s) | 50 | Default. Single-branch bank, mid-sized PSP. |
| premium | 10,000 (~166.7/s) | 500 | Multi-channel bank, large PSP. |
| unlimited | no per-key throttle | n/a | Tier-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 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:
- Mint a new key with the same scopes and tier (different
name, e.g.production-rig-primary-v2). - Deploy to half your fleet. Observe traffic on the new
key_prefixinlast_used_at. - Deploy to the rest of the fleet.
- Wait 24 hours.
- 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 -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).
| Status | Meaning |
|---|---|
| 204 | Revoked. |
| 403 | You're not the key's owner and not an admin. Audited as api_keys.revoke_denied (higher-priority signal than a plain permission denial). |
| 404 | No 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>.
| Credential | Issued by | Lifetime | Use case |
|---|---|---|---|
| JWT access token | POST /api/v1/auth/login | 15 minutes (configurable) | Interactive testing by the integration engineer. The admin UI uses this too. |
| API key | POST /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 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/keyfile. - 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.
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)
# 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
{
"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/refreshinvalidates the supplied refresh token and issues a fresh pair. Using the same refresh token twice fails withtoken_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
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_authentication | No Authorization header. |
| 401 | invalid_credentials | Header present but the token/key didn't verify. Could be: wrong token, expired token, revoked key, IP not in allowlist. |
| 401 | token_expired | JWT-specific. Refresh and retry. |
| 401 | token_revoked | Refresh-token replay. Force the user to log in again. |
| 403 | forbidden | Authenticated 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.
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.
| Field | Type | Validation | Meaning |
|---|---|---|---|
| external_id | string | required, max 255 | Your unique identifier for this transaction. The (merchant_id, external_id) pair is enforced unique — re-submitting the same pair returns the cached decision. |
| merchant_id | string | required, max 64 | The merchant identifier the platform admin assigned you. Must match the API key's merchant_id. |
| amount | number | required, ≥ 0 | Transaction amount in the major unit of currency (e.g. 12500 NGN = twelve thousand five hundred naira). |
| currency | string | required, ISO 4217 | Three-letter code: NGN, USD, EUR, etc. |
Channel & payment context (recommended)
| Field | Type | Meaning |
|---|---|---|
| channel | string | One 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_type | string | debit, credit, transfer, payment, withdrawal, deposit, refund, reversal, fee, interest, inquiry. |
| status | string | Lifecycle hint: pending, successful, failed, cancelled, reversed, on_hold, declined. |
| payment_method | string | Free-form, max 32. |
| transaction_reference | string | Your reference, max 64. |
Customer
| Field | Type | Meaning |
|---|---|---|
| customer_id | string | Your customer identifier, max 64. The first time the platform sees it, a stub customer record is created automatically. |
| customer_email | string | Optional, must look like an email. |
| customer_phone | string | Optional, max 32. E.164 preferred. |
| bvn_hash | string | HMAC-SHA256 of the customer's BVN. 64 hex chars. Never send the raw BVN — see PII rules. |
Card
| Field | Type | Meaning |
|---|---|---|
| card_bin | string | First 6 or 8 digits. Required for pos, atm, card_present, card_cnp. |
| card_last_four | string | Exactly 4 digits. |
| card_brand | string | visa, mastercard, verve, etc. |
| card_type | string | credit, debit, prepaid. |
| card_country | string | ISO 3166-1 alpha-3. |
| entry_mode | string | chip, contactless, magstripe, keyed, ecommerce, fallback, credential_on_file, unknown. |
| emv_cryptogram_present | bool | Required true when entry_mode is chip or contactless. EMV liability rule. |
| pos_pin_verified | bool | |
| pos_signature_verified | bool |
Terminal / POS
| Field | Type | Meaning |
|---|---|---|
| terminal_id | string | Up to 16 chars. Required for POS. |
| terminal_country | string | ISO 3166-1 alpha-3. |
| terminal_location_lat | number | −90 to 90. |
| terminal_location_lng | number | −180 to 180. |
| merchant_name | string | Up to 255. |
| merchant_city | string | Up to 100. |
| merchant_country | string | ISO 3166-1 alpha-3. |
| mcc | string | Merchant Category Code. Validated against the MCC tag. |
ATM (channel = atm)
| Field | Type | Meaning |
|---|---|---|
| atm_id | string | Required. Up to 20 chars. Cross-resolved against the registry; unknown atm_id returns 404 UNKNOWN_ATM. |
| withdrawal_amount | number | ≥ 0. |
| atm_location_lat | number | −90 to 90. |
| atm_location_lng | number | −180 to 180. |
| pin_attempts | number | 0–9. |
| is_foreign_card | bool | |
| transaction_subtype | string | withdrawal, 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_number | sender_nuban |
| source_account_name | sender_account_name |
| source_bank_code | sender_bank_id (int FK form) |
| dest_account_number | beneficiary_nuban |
| dest_account_name | beneficiary_name |
| dest_bank_code | beneficiary_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
| Field | Type | Meaning |
|---|---|---|
| device_id | string | Up to 128 chars. For mobile_app, also derived from X-Device-ID header. |
| device_type, device_os, device_browser | string | Self-explanatory. |
| app_version | string | Up to 40 chars. For mobile_app, also derived from X-App-Version header (header wins). |
| biometric_used | bool | mobile_app only. |
| biometric_type | string | none, face, fingerprint. |
| screen_locked_attempts | number | ≥ 0. |
| ip_address | string | Validated as a parseable IP. |
| ip_country, ip_region, ip_city | string | |
| is_vpn, is_proxy | bool |
Free-form
| Field | Type | Meaning |
|---|---|---|
| billing_address, shipping_address | object | Free-form JSON. |
| metadata | object | Free-form JSON. Persisted on the row, available for downstream rules + analytics. |
Wallet / agent banking (channel = wallet_transfer / agent_banking)
| Field | Type | Meaning |
|---|---|---|
| agent_id | string | NIBSS super-agent / sub-agent code. Up to 40 chars. |
| wallet_provider | string | opay, palmpay, moniepoint, kuda, carbon, fairmoney, cowrywise, sparkle, mtn_momo, airtel_smartcash, 9psb, hopepsb, pocketapp, other. |
| psb_transaction_subtype | string | cash_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.
| Field | Type | Validation | Meaning |
|---|---|---|---|
| balance_before | number | ≥ 0 | Customer balance immediately before the transaction posted. |
| balance_after | number | ≥ 0 | Customer balance after the transaction posted. |
| fee_amount | number | ≥ 0 | Fee charged on top of amount. |
| vat_amount | number | ≥ 0 | VAT applied to the fee (or to amount for VAT-bearing services). |
| session_id | string | max 64 | Your client-side session identifier. Useful for grouping a customer's actions across multiple /evaluate calls. |
| completed_at | timestamp | RFC 3339 | When the transaction reached terminal status on your side. Defaults to the platform's receive time. |
| status_reason | string | max 255 | Human-readable reason accompanying status (e.g. insufficient_funds, customer_cancelled). |
PII rules
- 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) andcard_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 -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 '{...}'
| Outcome | Status | Wire shape |
|---|---|---|
| First call with this key | 200 | The full DecisionResponse. |
| Replay (same key, same body) | 200 | Cached DecisionResponse, header X-Idempotent-Replay: true. |
| Conflict (same key, different body) | 409 | code: idempotency_conflict. |
| Still in flight | 409 | code: 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.
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.
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) | Type | Sourced from request field | Sourced from entity | Populated? |
|---|---|---|---|---|
| transaction.amount | number | amount | core.transactions.amount | Yes |
| transaction.currency | string | currency | core.transactions.currency | Yes |
| transaction.channel | string | channel (or URL channel) | core.transactions.channel | Yes |
| transaction.merchant_id | string | merchant_id | core.transactions.merchant_id | Yes |
| transaction.payment_method | string | payment_method | core.transactions.payment_method | Yes |
| transaction.transaction_type | string | transaction_type | core.transactions.transaction_type | Yes |
| transaction.direction | string | transaction_type (alias) | core.transactions.transaction_type | Yes |
| transaction.country | string | merchant_country | core.transactions.merchant_country | Yes |
| transaction.description | string | narration | core.transactions.narration | Yes |
| transaction.card_bin | string | card_bin | core.transactions.card_bin | Yes |
| transaction.card_last_four | string | card_last_four | core.transactions.card_last_four | Yes |
| transaction.card_brand | string | card_brand | core.transactions.card_brand | Yes |
| transaction.card_type | string | card_type | core.transactions.card_type | Yes |
| transaction.card_country | string | card_country | core.transactions.card_country | Yes |
| transaction.is_foreign_card | bool | derived: card_country != "" && card_country != "NG" | — | Yes (derived) |
| transaction.terminal_id | string | terminal_id | core.transactions.terminal_id | Yes |
| transaction.atm_id | string | atm_id | core.transactions.atm_id | Yes |
| transaction.entry_mode | string | entry_mode | core.transactions.entry_mode | Yes |
| transaction.pin_attempts | number | pin_attempts | core.transactions.pin_attempts | Yes |
| transaction.atm.deployment_type | string | (enriched from ATM registry by ATMRegistryResolver) | — | Yes |
| transaction.sender_nuban | string | sender_nuban | core.transactions.sender_nuban | Yes |
| transaction.beneficiary_nuban | string | beneficiary_nuban | core.transactions.beneficiary_nuban | Yes |
| transaction.source_bank_code | string | source_bank_code | core.transactions.source_bank_code | Yes |
| transaction.dest_bank_code | string | dest_bank_code | core.transactions.dest_bank_code | Yes |
| transaction.source_account_number | string | source_account_number | core.transactions.source_account_number | Yes |
| transaction.dest_account_number | string | dest_account_number | core.transactions.dest_account_number | Yes |
| transaction.narration | string | narration | core.transactions.narration | Yes |
| transaction.device_id | string | device_id (or X-Device-ID header) | core.transactions.device_id | Yes |
| transaction.ip_address | string | ip_address | core.transactions.ip_address | Yes |
| transaction.user_agent | string | captured from r.UserAgent() at the HTTP boundary (transient — not persisted) | — | Yes |
| transaction.session_id | string | session_id | core.transactions.session_id | Yes |
| customer.id | string | customer_id | core.customers.customer_number | Yes |
| customer.email | string | customer_email | core.customers.email | Yes |
| customer.phone | string | customer_phone | core.customers.phone | Yes |
| customer.kyc_tier | string | (from customer record) | core.customers.kyc_tier | Yes |
| customer.is_pep | bool | (from customer record) | core.customers.is_pep | Yes |
| customer.risk_score | number | (from customer record) | core.customers.risk_score | Yes |
| customer.fraud_count | number | (BVN-fingerprint derived via identity.Service) | — | Yes |
| customer.account_age_days | number | (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.
| Dimension | Sourced from request field(s) | Composite key | Hashed? |
|---|---|---|---|
| user | customer_id | — | No (opaque ID) |
| device | device_id | — | No (SDK-generated) |
| ip | ip_address | — | Yes (SHA-256) |
| card | card_bin + card_last_four | <bin>:<last4> | Yes (SHA-256) |
customer_email (lowercased) | — | Yes (SHA-256) | |
| phone | customer_phone | — | Yes (SHA-256) |
| agent | agent_id | — | No |
| terminal | terminal_id | — | No |
| merchant | merchant_id | — | No |
| sender_account | source_bank_code + source_account_number, OR sender_bank_id + sender_nuban | <bank>:<nuban> | Yes (SHA-256) |
| beneficiary_account | dest_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 type | Sourced from request field(s) | Hashed before lookup? |
|---|---|---|
| user | customer_id | No |
| customer | customer_id | No |
| device | device_id | No |
| card | card_bin + card_last_four | Yes |
| ip | ip_address | Yes |
customer_email | Yes | |
| phone | customer_phone | Yes |
| merchant | merchant_id | No |
| terminal | terminal_id | No |
| bvn | bvn_hash (caller supplies the hash) | Yes |
| nuban | sender_nuban / source_account_number / dest_account_number / beneficiary_nuban | Yes |
| account_bank_pair | source_bank_code + source_account_number (or sender alias) | Yes |
| beneficiary_account | dest_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.
| Channel | Required | Recommended (drives detection) | Ignored |
|---|---|---|---|
| pos | core + card_bin + terminal_id | card_last_four, entry_mode, emv_cryptogram_present, pos_pin_verified, terminal_country, terminal_location_lat/lng, merchant_country, mcc | A2A fields |
| atm | core + card_bin + atm_id | card_last_four, entry_mode, pin_attempts, withdrawal_amount, atm_location_lat/lng, is_foreign_card, transaction_subtype | A2A fields, terminal_* |
| card_present / card_cnp | core + card_bin | card_last_four, entry_mode, card_country, merchant_country, mcc, ip_address (CNP) | A2A fields, atm_id |
| nip / rtgs / intra_bank / ach / cheque | core + (source NUBAN + dest NUBAN, either form) + matching bank codes | narration, nip_session_id, stan, transaction_reference | card_*, atm_*, terminal_* |
| ussd | core | customer_phone, session_id, transaction_type, plus A2A for transfers | card_*, atm_*, terminal_* |
| mobile_app | core | device_id (or X-Device-ID), app_version (or X-App-Version), biometric_used, biometric_type, screen_locked_attempts, ip_address | card_*, atm_*, terminal_* |
| internet_banking | core | device_id, ip_address, is_vpn, is_proxy, device_browser, session_id | card_*, atm_*, terminal_* |
| agent_banking | core + (A2A for transfers) | agent_id, psb_transaction_subtype, terminal_id if super-agent device | card_* |
| wallet_transfer | core | wallet_provider, psb_transaction_subtype, A2A for wallet-to-bank, device_id, ip_address | card_*, atm_* |
| web | core | ip_address, device_id, ip_country, is_vpn, is_proxy, card_* for CNP | atm_* |
| nqr | core | merchant_id, terminal_id, mcc | atm_*, card_* |
/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:
| Channel | Default sustained RPS (per minute) | Burst |
|---|---|---|
| POS | 2,000 | 200 |
| ATM | 1,000 | 100 |
| USSD | 5,000 | 1,000 |
| Mobile app | 3,000 | 300 |
| Internet banking | 1,500 | 150 |
| NIP | 4,000 | 400 |
| RTGS | 200 | 20 |
| Intra-bank | 2,000 | 200 |
| Agent banking | 2,000 | 500 |
| Wallet transfer | 3,000 | 300 |
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 -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"
}'
{
"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 -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"
}'
{
"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 -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"
}'
{
"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"
}
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
{
"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_msare ALWAYS present.reason_codesandrecommended_actionsare always present but may be empty arrays.challengeis omitted unlessoutcome == "challenge". Branch on the field's presence, not on re-deriving from the outcome.request_idis 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
Clean. Proceed with the transaction; log the decision and move on.
review
Suspicious but not blockable inline. Approve, then queue for analyst review.
challenge
Step-up required. Surface the challenge.challenge_type; do NOT settle until challenge succeeds.
decline
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:
| Code | Category | When it fires |
|---|---|---|
| AMOUNT_HIGH | Amount | Transaction amount exceeds customer-segment norm. |
| AMOUNT_ROUND_NUMBER | Amount | Suspiciously round value (typical of mule cash-out). |
| BEHAVIOR_OFF_HOURS | Behavior | Outside the customer's typical active window. |
| BEHAVIOR_NEW_PAYEE | Behavior | First-time recipient for this customer. |
| DEVICE_NEW | Device | First sighting of this device for the customer. |
| DEVICE_FINGERPRINT_MISMATCH | Device | Device hash doesn't match the cached fingerprint. |
| GEO_HIGH_RISK_COUNTRY | Geo | IP / merchant geo is on the elevated-risk list. |
| GEO_IP_VELOCITY | Geo | Same IP has driven many distinct accounts recently. |
| VELOCITY_COUNT_24H | Velocity | Customer's transaction count in 24h exceeds tier norm. |
| LIST_BLOCKLIST_PAN | List | PAN matches an internal blocklist. |
| LIST_SANCTIONS | List | Sanctions-list hit. Usually drives decline regardless of score. |
| ML_HIGH_RISK | ML | Model output exceeded the configured ML threshold. |
| FRD_NIP_FANOUT | Graph | Mule-detector: account is fanning funds across many beneficiaries. |
| FRD_MOBILE_NEW_DEVICE | Device | Newly-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.
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
| Percentile | Budget |
|---|---|
| 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:
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json / invalid_body | Request body wasn't valid JSON or couldn't be read. |
| 404 | UNKNOWN_BANK | A bank code field (source_bank_code / dest_bank_code) doesn't match the platform's bank registry. |
| 404 | UNKNOWN_ATM | atm_id doesn't match the registry. |
| 409 | duplicate_transaction | (merchant_id, external_id) already exists. Response body carries the cached decision. |
| 409 | idempotency_conflict | Same X-Idempotency-Key, different body. |
| 409 | idempotency_in_flight | A prior request with the same key hasn't finished yet. Retry shortly. |
| 422 | validation_error | Wire-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.
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 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.
{
"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 "https://staging.kupinga.net/api/v1/decisions/$DECISION_ID?include=signals,transaction" \
-H "Authorization: Bearer $API_KEY"
| Include | Adds field | Meaning |
|---|---|---|
| signals | signals | The 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. |
| transaction | transaction | The 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.
# 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
| Situation | What 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
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_id | The path parameter isn't a parseable UUID. |
| 403 | forbidden | Your credential is valid but doesn't carry decisions:read. |
| 404 | not_found | No decision with that public_id. Final — don't retry in a loop. |
| 500 | internal_error | Platform-side error. Retry with exponential backoff. |
Customers
Read-mostly surface with a small write path for explicit CIF onboarding.
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:
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)
# Bash one-liner
printf '%s' "$BVN" | openssl dgst -sha256 -hmac "$PEPPER" -r | awk '{print $1}'
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 -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
}'
| Field | Required | Notes |
|---|---|---|
| customer_number | yes | Your CIF identifier, max 64 chars. Unique. |
| first_name, last_name | yes | Plain UTF-8. |
| yes | RFC-5322 validated. | |
| phone | yes | E.164. Validated. |
| date_of_birth | no | ISO date YYYY-MM-DD. |
| gender | no | M, F, or X. |
| bvn_hash, nin_hash | no¹ | HMAC-SHA256, 64-hex. |
| passport_hash, drivers_license_hash | no¹ | SHA-256/HMAC-SHA256, 64-hex. Used for screening grade on foreign nationals lacking BVN/NIN. |
| passport_expires_at, drivers_license_expires_at | no² | ISO date. Drives expiry notifications. |
| address_line1, address_line2 | no | Free-form, max 200. |
| city, state, postal_code | no | Free-form. |
| country | yes | ISO-3 country code (NGA, GBR, USA). |
| nationality | no | ISO-2 country code (NG, GB, US). |
| place_of_birth, business_name | no | Free-form. Set business_name for corporate customers. |
| kyc_tier | yes | tier_1, tier_2, tier_3. |
| is_pep | no | Defaults 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)
{
"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.
Search
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
{
"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.
Directors & beneficial owners — /related-parties
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.
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.
Create a related party + opening relationship
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.
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
| Status | Code | Meaning |
|---|---|---|
| 400 | missing_query | q parameter missing on search. |
| 400 | invalid_id | public_id not a UUID. |
| 400 | invalid_bvn_hash | BVN-hash path parameter isn't 64 hex chars. |
| 400 | validation_error | Body validation failed; fields[] names each. |
| 404 | not_found | No customer matches. |
| 409 | duplicate_customer | Duplicate customer_number on create. |
Webhooks
Push events to your endpoint instead of polling. Signed HMAC-SHA256, retried with backoff, replay-protected.
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
| Event | Fires when | Payload anchor |
|---|---|---|
| decision.created | /evaluate persists a decision (every outcome). | decision_id + transaction_id |
| alert.created | A new alert lands in the analyst inbox. | alert_id + customer_id |
| case.created | An alert escalates to a case. | case_id + alert_ids[] |
| case.resolved | A 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 -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"]
}'
| Field | Meaning | Edit path |
|---|---|---|
| name | Unique label within your scope. Lowercase + dashes recommended. | routine PATCH |
| description | Free-form. Surfaces in the admin console. | routine PATCH |
| target_url | HTTPS URL. HTTP is refused at create time. Subject to the SSRF guard + destination IP allowlist. | maker-checker |
| secret_ref | Vault path the platform reads the HMAC signing key from. Operator seeds the value during onboarding. | maker-checker |
| events | Array of event names. Must be in the allowlist. | maker-checker |
| headers | Optional static headers added to every delivery. | routine PATCH |
| timeout_ms | Per-attempt HTTP timeout. Default 5,000. Range 500–30,000. | routine PATCH |
| is_active | Set true to start receiving deliveries immediately. false to provision-then-flip. | maker-checker |
| status | active / suspended / retired. Auto-suspension is driven by consecutive_failures. | maker-checker |
| ip_allowlist | Optional INET[] of CIDRs / bare IPs the resolved destination must fall inside. | maker-checker |
| consecutive_failures_max | After 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:
| Header | Meaning |
|---|---|
| Content-Type | application/json |
| X-Event-Type | Event type, e.g. case.created. |
| X-Event-Id | UUID of the event. Use this for consumer-side deduplication. |
| X-Delivery-Id | UUID of THIS delivery attempt. Same event_id may carry different delivery_id across retries. |
| X-Signature | HMAC-SHA256 over the body, hex-encoded, prefixed sha256=. |
| User-Agent | antifraud-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).
{
"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.
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)
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.
| Range | Why it's blocked |
|---|---|
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | RFC1918 — internal infra (PG, Redis, sibling units). |
127.0.0.0/8, ::1/128 | Loopback — self-targeting from the dispatcher host. |
169.254.0.0/16 | Link-local — covers cloud instance metadata services (IMDS at 169.254.169.254). |
fc00::/7 | IPv6 unique-local — IPv6 equivalent of RFC1918. |
fe80::/10 | IPv6 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_reason | Cause |
|---|---|
ssrf_private | A resolved IP fell in the unconditional block list above. |
not_in_allowlist | A resolved IP fell outside ip_allowlist. |
dns_failure | The 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:
# 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:
| Attempt | Wait before retry |
|---|---|
| 1 | (initial delivery) |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 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
# 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.
| Tier | Sustained per minute | Burst | Typical fit |
|---|---|---|---|
| starter | 100 | 20 | Smoke test / POC |
| standard | 1,000 | 50 | Single-branch bank, mid-sized PSP |
| premium | 10,000 | 500 | Multi-channel bank, large PSP |
| unlimited | no throttle | — | Negotiated (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:
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Tier limit + burst, i.e. the bucket size. |
| X-RateLimit-Remaining | How many calls remain in the current window. |
| X-RateLimit-Reset | Unix timestamp (seconds) when the bucket next refills. |
| Retry-After | (On denial only) Seconds to wait before retrying. Always ≥ 1. |
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:
{
"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
| Code | Meaning | Retryable? |
|---|---|---|
invalid_json | Request body wasn't parseable JSON. | No. Fix the body. |
invalid_body | Body couldn't be read at all. | No. |
invalid_input | Request parsed but a field is malformed. | No. |
invalid_id | Path UUID is malformed. | No. |
invalid_bvn_hash | BVN-hash path parameter isn't 64 hex chars. | No. |
invalid_tier | Unknown API-key tier on a key-mint request. | No. |
unknown_scope | Scope not in the catalogue on a key-mint request. | No. |
missing_query | Required query parameter missing. | No. |
invalid_event | Webhook event name not in the allowlist. | No. |
missing_refresh_token | /auth/refresh called without refresh_token. | No. |
401 — Unauthorized
| Code | Meaning | Retryable? |
|---|---|---|
missing_authentication | No Authorization header. | No. Add the header. |
invalid_credentials | Bearer present, didn't verify. | No. Mint a fresh key / log in again. |
token_expired | JWT access token past its 15-minute lifetime. | Yes after /auth/refresh. |
token_revoked | Refresh token replay. | No. Force re-login. |
403 — Forbidden
| Code | Meaning | Retryable? |
|---|---|---|
forbidden | Authenticated but scope is insufficient. | No. Request the missing scope from your admin. |
404 — Not found
| Code | Meaning | Retryable? |
|---|---|---|
not_found | Resource doesn't exist (decision, customer, webhook). | No. |
UNKNOWN_BANK | Bank code not in the registry. | No. Fix the bank code. |
UNKNOWN_ATM | ATM 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
| Code | Meaning | Retryable? |
|---|---|---|
duplicate_transaction | (merchant_id, external_id) already exists. Response body carries the cached decision. | No — the answer is in the body. |
idempotency_conflict | Same X-Idempotency-Key, different body. | No. Mint a fresh key. |
idempotency_in_flight | Prior request with the same key hasn't finished. | Yes after a short backoff (200–500 ms). |
conflict | Generic create-time collision (e.g. webhook name reuse). | No. |
422 — Unprocessable entity
| Code | Meaning | Retryable? |
|---|---|---|
validation_error | Wire-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
| Code | Meaning | Retryable? |
|---|---|---|
rate_limit_exceeded | Per-key tier or per-channel bucket exhausted. | Yes after Retry-After. |
500 / 503 — Server
| Code | Meaning | Retryable? |
|---|---|---|
internal_error | Platform-side fault. | Yes with exponential backoff (1s, 2s, 4s, 8s, 16s, give up). |
service_unavailable | Platform 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:
| Condition | Strategy |
|---|---|
200, 201, 204 | Don't retry. Done. |
304 | Don't retry. The body you have is current. |
400 / 401 / 403 / 404 / 422 | Don't retry. Fix the request. |
409 duplicate_transaction | Don't retry. The body carries the answer. |
409 idempotency_in_flight | Retry after 200 ms, then 500 ms, then give up. |
429 | Retry after Retry-After seconds. |
500 / 502 / 503 / 504 | Retry 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
Authorizationheader. Some proxies do this by default.
Latency expectations
| Endpoint | p50 budget | p99 budget |
|---|---|---|
POST /evaluate | 300 ms | 500 ms |
GET /decisions/{id} | 25 ms | 100 ms |
GET /customers?q=... | 50 ms | 200 ms |
GET /customers/{id} | 25 ms | 100 ms |
POST /auth/login | 200 ms | 500 ms (bcrypt is intentionally slow) |
POST /auth/refresh | 25 ms | 100 ms |
POST /api-keys | 50 ms | 150 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,transactionreturns enriched data without 5xx. If-None-Matchrevalidation lands a304on the second call.
Idempotency
- Same
X-Idempotency-Key+ same body → 200 withX-Idempotent-Replay: trueon retry. - Same
X-Idempotency-Key+ different body → 409idempotency_conflict. - Same
(merchant_id, external_id), no idempotency key → 409duplicate_transactionwith 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_timestampmore than 5 minutes in the past is rejected. - Consumer-side idempotency: the same
X-Event-Iddelivered 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
429responses 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
500msfor/evaluatethroughout the run. - Webhook deliveries don't queue up:
GET /admin/webhooks/{id}/healthshowsin_flightreturning to 0 within seconds of the load test ending.
Error handling
- Your client retries
429withRetry-After. - Your client retries
5xxand network errors with exponential backoff, capped at 5 attempts. - Your client does NOT retry
4xxerrors other than429and409 idempotency_in_flight. - Your client logs
request_idon 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:
- Tenant cloned to production. A new merchant_id provisioned under the production cluster, same shape as staging.
- Production API key minted. Same scopes as staging, the contracted tier, and an
allowed_cidrslist populated from your IP range. Delivered out-of-band. Later changes to the allowlist require a two-person maker-checker approval. - Production webhook subscription created (when contracted), with a fresh HMAC secret in production Vault. Staging secret never reused.
- 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:
- Flip the API key in your secret manager to the production value.
- Flip the base URL in your client config to the production host.
- Flip your webhook endpoint to the production handler.
- Smoke test the production tenant with a low-value transaction (e.g. ₦100 internal test). Confirm the decision lands and the webhook fires.
- 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
/evaluatefor 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
| Situation | Channel | SLA |
|---|---|---|
| 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 request | integration@<your-domain> | 2 business days |
| Webhook reconfiguration | integration@<your-domain> | 1 business day |
| Maintenance announcements | status.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/:
antifraud-rest-api.postman_collection.jsonantifraud-staging.postman_environment.jsonantifraud-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
| Environment | Base URL | Tier | IP allowlist enforced? |
|---|---|---|---|
| Staging | https://staging.kupinga.net | standard | No (audited) |
| Production | (per-tenant, emailed at cutover) | per contract | Yes |
Support
Where to go when things go sideways.
| Channel | Use 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). |
| /status | Live 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.