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 isWho is verified
Platform KYCVerifying your organization. Gates billing setup and unlocks stablecoin payments.You
White-label KYC / KYBVerifying 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.

HTTP
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:

JSON
{
  "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.

HTTP
GET /api/kyc/status
GET /api/kyc/status
JSON
{ "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

HTTP
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:

HTTP
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" }
JSON
{ "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:

HTTP
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:

JSON
{
  "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:

HTTP
GET /api/kyc/workflows
GET /api/kyc/workflows
JSON
{
  "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.

HTTP
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 webhookSecret in the response to generateSecret: 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

EventWhen it fires
kyc.completedAn individual verification reached a final decision.
kyc.status_updatedInterim progress (In Progress → In Review, etc).
kyc.data_updatedVerification data changed without a status transition.
kyb.completedA business verification reached a final decision.
kyb.status_updatedInterim progress on a KYB session.
kyb.data_updatedRegistry or document data changed.
user.status_updatedA previously approved person was flagged or blocked by ongoing screening.
user.data_updatedMonitored profile data changed.
business.status_updatedA verified company was flagged or blocked.
business.data_updatedMonitored company data changed.
activity.createdA monitoring activity was recorded.
transaction.createdA monitored transaction was recorded.
transaction.status_updatedTransaction screening reached a decision.
travel_rule.status_updatedA travel-rule exchange progressed.
workflow.compliance_changedThe 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:

JSON
{
  "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:

JSON
{
  "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:

HeaderMeaning
x-mudbase-eventThe event name.
x-mudbase-signatureHMAC-SHA256 (hex) of the raw request body, keyed with your signing secret.
x-mudbase-timestampUnix 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.

JavaScript
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);
});
Python
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}, 200
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}, 200

Always 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:

HTTP
POST /api/kyc/webhook-config/test
POST /api/kyc/webhook-config/test
JSON
{ "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:

HTTP
GET /api/kyc/events?limit=25
GET /api/kyc/events?limit=25
JSON
{
  "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

EndpointRoles
POST /api/kyc/sessionsowner, admin
GET /api/kyc/statusany member
GET/PUT /api/kyc/webhook-configowner, admin
POST /api/kyc/webhook-config/testowner, admin
GET /api/kyc/workflowsowner, admin
GET /api/kyc/eventsowner, admin, developer
POST /api/projects/{id}/kyc/sessionsproject access
POST /api/projects/{id}/kyb/sessionsproject access