Guides
Identity & business verification (KYC / KYB)
Verify identities with Mudbase — your own organization, and your end-users and business customers. Everything below is configured on the KYC console page; this guide is its reference.
There are two distinct uses:
| What it is | Who is verified | |
|---|---|---|
| Platform KYC | Verifying your organization. Gates billing setup and unlocks stablecoin payments. | You |
| White-label KYC / KYB | Verifying your users and business customers, under your own brand. | Your customers |
White-label verification is a resale feature: your application never talks to the verification vendor. You call Mudbase, and Mudbase sends you normalized, signed events.
Platform KYC
Start a session for your own organization. Owner/admin only.
POST /api/kyc/sessions
Authorization: Bearer {token}
Content-Type: application/json
{ "language": "en" }POST /api/kyc/sessions
Authorization: Bearer {token}
Content-Type: application/json
{ "language": "en" }Response 201:
{
"sessionId": "7b49ea83-9539-4eab-961d-68f9e7e7935a",
"url": "https://verify.didit.me/session/anshN4VifI_6",
"status": "Not Started"
}{
"sessionId": "7b49ea83-9539-4eab-961d-68f9e7e7935a",
"url": "https://verify.didit.me/session/anshN4VifI_6",
"status": "Not Started"
}Redirect the user to url. When they finish, the result arrives by webhook and your organization's status flips to approved.
GET /api/kyc/statusGET /api/kyc/status{ "status": "approved", "verifiedAt": "2026-07-24T09:15:02.441Z" }{ "status": "approved", "verifiedAt": "2026-07-24T09:15:02.441Z" }status is one of none, pending, approved, declined.
White-label KYC — verifying your end-users
POST /api/projects/{projectId}/kyc/sessions
Authorization: Bearer {token}
Content-Type: application/json
{
"workflowId": "c8b4de3d-d35a-4dae-a637-67715be2247c",
"vendorData": "your-internal-user-id",
"callback": "https://your-app.com/verification/done",
"language": "en"
}POST /api/projects/{projectId}/kyc/sessions
Authorization: Bearer {token}
Content-Type: application/json
{
"workflowId": "c8b4de3d-d35a-4dae-a637-67715be2247c",
"vendorData": "your-internal-user-id",
"callback": "https://your-app.com/verification/done",
"language": "en"
}vendorData is your own identifier. It is echoed back on every webhook, so it is how you match a result to a user in your database. Use a stable id, not an email.
Reusable KYC
If the same person already completed an approved verification anywhere on Mudbase, you can satisfy a new verification instantly instead of making them repeat it:
POST /api/projects/{projectId}/kyc/check-reuse
Content-Type: application/json
{ "type": "email", "value": "ada@example.com" }POST /api/projects/{projectId}/kyc/check-reuse
Content-Type: application/json
{ "type": "email", "value": "ada@example.com" }{ "reusable": true, "status": "approved", "verifiedAt": "2026-05-02T11:20:00.000Z" }{ "reusable": true, "status": "approved", "verifiedAt": "2026-05-02T11:20:00.000Z" }Pass the same identifier as reuseIdentifier when creating the session and the flow short-circuits: the response comes back "reused": true with status: "Approved" and no url to visit. Identifiers are stored as a peppered hash, never in the clear.
White-label KYB — verifying business customers
Same idea, for companies. The workflow decides the product, so KYB uses its own endpoint but the identical shape:
POST /api/projects/{projectId}/kyb/sessions
Authorization: Bearer {token}
Content-Type: application/json
{
"vendorBusinessId": "acme-inc",
"workflowId": "8ca1868a-...",
"callback": "https://your-app.com/onboarding/done"
}POST /api/projects/{projectId}/kyb/sessions
Authorization: Bearer {token}
Content-Type: application/json
{
"vendorBusinessId": "acme-inc",
"workflowId": "8ca1868a-...",
"callback": "https://your-app.com/onboarding/done"
}Response 201:
{
"sessionId": "kyb-uuid",
"url": "https://verify.didit.me/session/...",
"status": "Not Started",
"kind": "kyb"
}{
"sessionId": "kyb-uuid",
"url": "https://verify.didit.me/session/...",
"status": "Not Started",
"kind": "kyb"
}workflowId is optional — it falls back to the default KYB workflow you selected in the console, then to the platform default. Results arrive as kyb.completed.
Choosing workflows
Rather than hardcoding workflow UUIDs, pick defaults once in the console. To list what is available:
GET /api/kyc/workflowsGET /api/kyc/workflows{
"workflows": [{ "id": "c8b4de3d-...", "name": "Free KYC", "type": "kyc", "isDefault": true }],
"kyc": [ ... ],
"kyb": [ ... ]
}{
"workflows": [{ "id": "c8b4de3d-...", "name": "Free KYC", "type": "kyc", "isDefault": true }],
"kyc": [ ... ],
"kyb": [ ... ]
}Webhooks
Configure one endpoint that receives every compliance event you subscribe to.
PUT /api/kyc/webhook-config
Content-Type: application/json
{
"webhookUrl": "https://your-app.com/webhooks/kyc",
"events": ["kyc.completed", "kyb.completed", "user.status_updated"],
"generateSecret": true
}PUT /api/kyc/webhook-config
Content-Type: application/json
{
"webhookUrl": "https://your-app.com/webhooks/kyc",
"events": ["kyc.completed", "kyb.completed", "user.status_updated"],
"generateSecret": true
}The signing secret is returned exactly once, as
webhookSecretin the response togenerateSecret: true. It is never retrievable again — store it before you navigate away. Regenerating immediately invalidates the previous secret.
Leaving events empty means the defaults apply: kyc.completed and kyb.completed only. Selecting any event replaces the defaults entirely, so an explicit list is the complete list.
Event catalogue
| Event | When it fires |
|---|---|
kyc.completed | An individual verification reached a final decision. |
kyc.status_updated | Interim progress (In Progress → In Review, etc). |
kyc.data_updated | Verification data changed without a status transition. |
kyb.completed | A business verification reached a final decision. |
kyb.status_updated | Interim progress on a KYB session. |
kyb.data_updated | Registry or document data changed. |
user.status_updated | A previously approved person was flagged or blocked by ongoing screening. |
user.data_updated | Monitored profile data changed. |
business.status_updated | A verified company was flagged or blocked. |
business.data_updated | Monitored company data changed. |
activity.created | A monitoring activity was recorded. |
transaction.created | A monitored transaction was recorded. |
transaction.status_updated | Transaction screening reached a decision. |
travel_rule.status_updated | A travel-rule exchange progressed. |
workflow.compliance_changed | The compliance configuration behind a workflow changed. |
user.status_updated and business.status_updated matter even after approval: they are how you learn that an existing customer has become sanctioned or otherwise unacceptable. Subscribe to them if you have any ongoing obligation.
Payload
Every delivery is a flat JSON object. Completion events look like:
{
"event": "kyc.completed",
"deliveredAt": "2026-07-24T09:15:02.441Z",
"kind": "kyc",
"status": "Approved",
"sessionId": "7b49ea83-9539-4eab-961d-68f9e7e7935a",
"userId": null,
"projectId": "6650f2c9a1b2c3d4e5f60718",
"vendorData": "your-internal-user-id",
"vendorBusinessId": null,
"decisionSummary": {
"approved": true,
"documentType": "PASSPORT",
"country": "NG",
"reason": null
},
"verifiedAt": "2026-07-24T09:15:02.441Z",
"reused": false
}{
"event": "kyc.completed",
"deliveredAt": "2026-07-24T09:15:02.441Z",
"kind": "kyc",
"status": "Approved",
"sessionId": "7b49ea83-9539-4eab-961d-68f9e7e7935a",
"userId": null,
"projectId": "6650f2c9a1b2c3d4e5f60718",
"vendorData": "your-internal-user-id",
"vendorBusinessId": null,
"decisionSummary": {
"approved": true,
"documentType": "PASSPORT",
"country": "NG",
"reason": null
},
"verifiedAt": "2026-07-24T09:15:02.441Z",
"reused": false
}Monitoring events carry the subject and its new state instead:
{
"event": "user.status_updated",
"entityType": "user",
"status": "BLOCKED",
"previousStatus": "ACTIVE",
"vendorData": "your-internal-user-id",
"projectId": "6650f2c9a1b2c3d4e5f60718"
}{
"event": "user.status_updated",
"entityType": "user",
"status": "BLOCKED",
"previousStatus": "ACTIVE",
"vendorData": "your-internal-user-id",
"projectId": "6650f2c9a1b2c3d4e5f60718"
}Payloads are deliberately non-PII: status, identifiers, and a small decision summary. Document images and extracted identity fields are never transmitted — read them from the verification record if you need them.
Verifying the signature
Each request carries:
| Header | Meaning |
|---|---|
x-mudbase-event | The event name. |
x-mudbase-signature | HMAC-SHA256 (hex) of the raw request body, keyed with your signing secret. |
x-mudbase-timestamp | Unix seconds when the delivery was sent. |
Compute the HMAC over the exact bytes you received — do not JSON.parse and re-stringify first, or whitespace and key-order differences will break the comparison.
import crypto from "crypto";
import express from "express";
const app = express();
// Capture the raw body BEFORE json parsing.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
app.post("/webhooks/kyc", (req, res) => {
const expected = crypto
.createHmac("sha256", process.env.MUDBASE_KYC_SECRET)
.update(req.rawBody)
.digest("hex");
const received = req.header("x-mudbase-signature") ?? "";
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: "bad signature" });
}
// Reject replays.
const ts = Number(req.header("x-mudbase-timestamp"));
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(401).json({ error: "stale" });
}
res.status(200).json({ received: true }); // ack fast, then process
handleEvent(req.body);
});import crypto from "crypto";
import express from "express";
const app = express();
// Capture the raw body BEFORE json parsing.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
app.post("/webhooks/kyc", (req, res) => {
const expected = crypto
.createHmac("sha256", process.env.MUDBASE_KYC_SECRET)
.update(req.rawBody)
.digest("hex");
const received = req.header("x-mudbase-signature") ?? "";
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: "bad signature" });
}
// Reject replays.
const ts = Number(req.header("x-mudbase-timestamp"));
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(401).json({ error: "stale" });
}
res.status(200).json({ received: true }); // ack fast, then process
handleEvent(req.body);
});import hmac, hashlib, time
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/kyc")
def kyc_webhook():
expected = hmac.new(
SECRET.encode("utf-8"), request.get_data(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("x-mudbase-signature", "")):
return {"error": "bad signature"}, 401
ts = int(request.headers.get("x-mudbase-timestamp", 0))
if abs(time.time() - ts) > 300:
return {"error": "stale"}, 401
handle_event(request.get_json())
return {"received": True}, 200import hmac, hashlib, time
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/kyc")
def kyc_webhook():
expected = hmac.new(
SECRET.encode("utf-8"), request.get_data(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("x-mudbase-signature", "")):
return {"error": "bad signature"}, 401
ts = int(request.headers.get("x-mudbase-timestamp", 0))
if abs(time.time() - ts) > 300:
return {"error": "stale"}, 401
handle_event(request.get_json())
return {"received": True}, 200Always use a constant-time comparison (timingSafeEqual / compare_digest). A plain == leaks the expected signature one byte at a time.
Requirements for your endpoint
- HTTPS only. Plain HTTP endpoints are rejected when you save the URL.
- Public address. Private, loopback, and link-local addresses are refused, and the outbound connection is pinned to the validated IP — a DNS record that later resolves inward will not be followed. Redirects are not followed either.
- Answer quickly with 2xx. Acknowledge first, process afterwards.
- Expect redelivery. Terminal events retry on the next callback if your server returns 5xx, so make handlers idempotent on
sessionId.
Testing your integration
Send yourself a signed sample delivery — it ignores your event subscription, so it always arrives:
POST /api/kyc/webhook-config/testPOST /api/kyc/webhook-config/test{ "ok": true, "httpStatus": 200, "error": null }{ "ok": true, "httpStatus": 200, "error": null }Or use Send test event on the console page. The sample uses "event": "kyc.test" with "test": true, so you can safely ignore it in production code paths.
Inspecting what happened
Every inbound event and its forwarding outcome is recorded:
GET /api/kyc/events?limit=25GET /api/kyc/events?limit=25{
"events": [
{
"webhookType": "status.updated",
"mudbaseEvent": "kyc.completed",
"sessionId": "7b49ea83-...",
"sessionKind": "user",
"status": "Approved",
"handled": true,
"handlerError": null,
"delivery": { "attempted": true, "ok": true, "httpStatus": 200, "error": null },
"receivedAt": "2026-07-24T09:15:02.441Z"
}
]
}{
"events": [
{
"webhookType": "status.updated",
"mudbaseEvent": "kyc.completed",
"sessionId": "7b49ea83-...",
"sessionKind": "user",
"status": "Approved",
"handled": true,
"handlerError": null,
"delivery": { "attempted": true, "ok": true, "httpStatus": 200, "error": null },
"receivedAt": "2026-07-24T09:15:02.441Z"
}
]
}This is the first place to look when a verification succeeded but your application never heard about it — it distinguishes "we never received it", "we received it but you were not subscribed", and "we tried to deliver and your endpoint failed".
Permissions
| Endpoint | Roles |
|---|---|
POST /api/kyc/sessions | owner, admin |
GET /api/kyc/status | any member |
GET/PUT /api/kyc/webhook-config | owner, admin |
POST /api/kyc/webhook-config/test | owner, admin |
GET /api/kyc/workflows | owner, admin |
GET /api/kyc/events | owner, admin, developer |
POST /api/projects/{id}/kyc/sessions | project access |
POST /api/projects/{id}/kyb/sessions | project access |