KYCoff API (version 1)
Your own systems can ask KYCoff to verify a person and receive the result. The person gets a personal link, completes the identity check (document, face, liveness, proof of address), your team reviews it in the review desk, and your system is told the outcome.
Base address: https://<your-firm>.kycoff.com/api/v1 (or your own domain).
Authentication
Create a key in the review desk: Settings › API and webhooks (MLRO only). Send it with every request:
Authorization: Bearer kyc_live_...
Keys are shown once and stored only as a fingerprint. Revoke a key at any time in the same screen. Limit: 120 requests a minute per key.
Create a verification request
POST /api/v1/verifications
Content-Type: application/json
{"name": "Maria Borg", "email": "maria@example.com", "reference": "CLIENT-1042", "send_email": true}
name,email: required. The email is fixed for the check.reference: optional, your own identifier (up to 100 characters).send_email: optional, defaulttrue. Setfalseto send the link yourself (SMS, your app, in person).
Response 201:
{"id": "VR-2026-7KQ2M9XA", "status": "sent", "link": "https://harbour.kycoff.com/#v=...", "expires_at": "2026-10-30T09:12:00+00:00",
"reference": "CLIENT-1042", "name": "Maria Borg", "email": "maria@example.com", "case_id": null, "decision": null, ...}
The link is returned only here (and when a request is renewed in the review desk). It works for one check and expires after 14 days.
Read a request
GET /api/v1/verifications/VR-2026-7KQ2M9XA GET /api/v1/verifications?status=approved&limit=50
status is one of:
| Status | Meaning |
|---|---|
sent | Link created, not opened yet |
opened | The person opened the link |
in_review | Submitted; your team is reviewing it |
info_requested | Your team asked the person for more information |
approved / rejected | Decided; decision holds the date and, when approved, risk_level |
cancelled / expired / withdrawn | No longer active |
Once the person has submitted, verified_person holds who actually completed the check: given_names, surname, birth_date, nationalities, document_type, document_issuer, the last four characters of the document number, and name_matches_request (whether that name matches the name you sent the link to). If it is false, the link was used by someone else: your reviewer is also warned in the review desk.
Cancel a request
POST /api/v1/verifications/VR-2026-7KQ2M9XA/cancel
Only before the person submits.
Download the report
GET /api/v1/verifications/VR-2026-7KQ2M9XA/report.pdf
The customer due diligence report (PDF) once the person is approved. Only keys created with Allow this key to download PDF reports can use this, up to 60 reports an hour; the report holds the full case file, including reviewers' notes. Each download is recorded in the case's audit trail.
Webhooks
Set the address in Settings › API and webhooks. KYCoff sends a POST with JSON when:
verification.submitted, verification.approved, verification.rejected, verification.info_requested
{"id": "evt_3f2a...", "type": "verification.approved", "created": "2026-10-16T10:01:02+00:00", "data": { ...the request, as GET returns it... }}
Delivery is retried 3 times. Answer with any 2xx status; redirects are not followed. Addresses must be public https URLs.
Checking the signature
Every webhook carries KYCoff-Signature: t=<unix time>,v1=<hex>, where v1 is HMAC-SHA256 of "<t>." + raw body, keyed with your signing secret (shown once when you save the address). Reject messages with a wrong signature or a t older than 5 minutes.
import hashlib, hmac, time
def valid(secret: str, header: str, body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"]) and abs(time.time() - int(parts["t"])) < 300
const crypto = require("crypto");
function valid(secret, header, rawBody) {
const p = Object.fromEntries(header.split(",").map((x) => x.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${p.t}.`).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1)) && Math.abs(Date.now() / 1000 - p.t) < 300;
}
Errors
Errors return JSON {"error": "..."} with 400 (invalid input), 401 (key missing, wrong or revoked), 404, 409 (report not available yet), 429 (too many requests).
What the API does not do
Decisions are made by people in the review desk; the API cannot approve or reject. Evidence images are not returned through the API; they stay in KYCoff and appear in the PDF report.