Documentation
Integrate VerifyKit
Add automated identity verification to any website with one API call and one webhook.
Quickstart
- Create an account, then open Settings and add your website's address (e.g.
https://shop.example.com) to Allowed return origins. - Create a test API key under API keys. Keep it on your server only.
- Add a webhook endpoint under Webhooks and store the signing secret.
- When a customer clicks “Verify”, call the API from your backend and redirect them to
hosted_url. - Update the customer when the webhook arrives. Switch to a live key when you are ready.
Ready-made integrations for Laravel, Express and Next.js are in the examples/ folder of the project.
Authentication
Send your secret key as a bearer token. Keys start with sk_test_ or sk_live_; test keys never touch live data and are never billed. Never use a key in a browser or mobile app.
Authorization: Bearer sk_test_xxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxEach key has scopes: verifications:write, verifications:read, verifications:read_pii (extracted name, date of birth and document number), webhooks:manage and usage:read.
Create a verification
POST/api/v1/verifications
Creates a verification and returns a single-use hosted_url valid for 2 hours. If an unfinished verification already exists for the same external_reference, it is returned with a fresh link instead of creating a duplicate.
| Field | Description |
|---|---|
| external_reference | Required. Your ID for the customer, e.g. your user ID. |
| document_type | Optional. passport, national_id or driving_license. If omitted, the customer chooses from the types enabled in Settings. |
| country_code | Optional. ISO 3166-1 alpha-2, e.g. BD. |
| return_url | Optional. Where to send the customer afterwards. Its origin must be allowed in Settings. |
| metadata | Optional. Flat object of strings, numbers or booleans, returned as-is. |
curl -X POST https://kyc.petveli.com/api/v1/verifications \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: user-42-attempt-1" \
-H "Content-Type: application/json" \
-d '{
"external_reference": "user_42",
"document_type": "national_id",
"return_url": "https://shop.example.com/kyc/done"
}'{
"id": "ver_3k9fQ2...",
"object": "verification",
"external_reference": "user_42",
"status": "created",
"document_type": "national_id",
"hosted_url": "https://kyc.petveli.com/verify/Xy7...",
"hosted_url_expires_at": "2026-10-12T12:00:00.000Z",
...
}The redirect back to return_url (with ?verification_id=) is not proof of success. Always rely on the webhook or a server-side GET.
Retrieve & list
GET/api/v1/verifications/:id
GET/api/v1/verifications?status=approved&limit=25
List results are newest first. Paginate with starting_after=<last id> while has_more is true. Keys with verifications:read_pii also receive an identity object:
{
"id": "ver_3k9fQ2...",
"status": "approved",
"reasons": ["all_checks_passed"],
"identity": {
"full_name": "KARIM RAHMAN",
"date_of_birth": "1990-05-01",
"document_number": "1234567890",
"expiry_date": null,
"source": "ocr"
},
"decided_at": "2026-10-12T10:02:00.000Z"
}Cancel & new links
POST/api/v1/verifications/:id/cancel
POST/api/v1/verifications/:id/session
Issue a new hosted link if the previous one expired before the customer finished.
Webhooks
We send a POST with a JSON body to your endpoint for these events: verification.created, verification.submitted, verification.approved, verification.declined, verification.review_required, verification.expired, verification.cancelled. Respond with any 2xx. Failed deliveries are retried with exponential backoff for about a day, and events can arrive more than once, so de-duplicate on id.
{
"id": "evt_8Qm...",
"type": "verification.approved",
"created_at": "2026-10-12T10:02:00.000Z",
"data": {
"verification_id": "ver_3k9fQ2...",
"external_reference": "user_42",
"status": "approved"
}
}Verify every request: compute HMAC-SHA256(secret, timestamp + "." + rawBody), compare it in constant time with the X-KYC-Signature header (v1=<hex>), and reject timestamps (X-KYC-Timestamp) older than 5 minutes.
const crypto = require("node:crypto");
function verifyWebhook(rawBody, headers, secret) {
const ts = headers["x-kyc-timestamp"];
const sig = String(headers["x-kyc-signature"] || "").replace(/^v1=/, "");
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("stale");
const expected = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest();
const given = Buffer.from(sig, "hex");
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
throw new Error("bad signature");
}
return JSON.parse(rawBody);
}$raw = $request->getContent();
$ts = $request->header('X-KYC-Timestamp');
$sig = substr((string) $request->header('X-KYC-Signature'), 3);
if (! $ts || abs(time() - (int) $ts) > 300) abort(400);
$expected = hash_hmac('sha256', $ts.'.'.$raw, config('services.verifykit.webhook_secret'));
if (! hash_equals($expected, $sig)) abort(400);
$event = json_decode($raw, true);Statuses
| created | Created by your backend; the customer has not opened the link yet. |
| pending | The customer opened the link and is capturing documents. |
| processing | Documents submitted; the engine is analysing them. |
| approved | All checks passed (or a reviewer approved it). Final. |
| declined | A clear failure such as a face mismatch or expired document (or a reviewer declined it). Final. |
| review_required | Inconclusive; a person on your team should decide in the dashboard. |
| expired | The customer did not finish within 7 days. |
| cancelled | Cancelled through the API. |
| error | Automated analysis failed repeatedly; review it manually. |
Reason codes
The reasons array explains a decision; the first entry is the main reason.
| all_checks_passed | All checks passed |
| face_mismatch | Selfie does not match the document photo |
| face_match_low_confidence | Face match is borderline |
| face_match_unavailable | Face match could not be computed |
| document_expired | Document is expired |
| underage | Applicant is under the minimum age |
| mrz_checksum_failed | MRZ check digits do not validate (misread or tampered) |
| passport_mrz_unreadable | Passport MRZ could not be read |
| national_id_without_mrz | National ID has no readable MRZ |
| driving_license_needs_review | Driving licence data was read with OCR only and needs a human check |
| document_unreadable | Document text could not be read |
| document_not_recognized | Not a recognised identity document (no readable data and no portrait) |
| document_number_missing | Document number not found |
| document_portrait_not_found | No portrait found on the document |
| front_back_mismatch | The name on the front does not match the name on the back |
| document_back_shows_portrait | The back-side photo shows a portrait (the front was likely photographed twice) |
| document_image_quality | Document photo quality is poor |
| date_of_birth_unreadable | Date of birth could not be parsed |
| date_of_birth_missing | Date of birth not found |
| selfie_face_not_found | No face found in the selfie |
| selfie_multiple_faces | Multiple faces in the selfie |
| liveness_failed | Head-movement liveness check failed |
| liveness_frames_missing | Liveness frames missing a face |
| liveness_face_changed | A different person appeared during the live check |
| liveness_frames_inconsistent | Liveness frames are inconsistent with the selfie |
| liveness_spoof_detected | Presentation attack detected (screen, replayed video, print or mask) |
| liveness_spoof_suspected | Possible presentation attack (screen, replay or print); check the frames |
| liveness_spoof_unavailable | Anti-spoof check could not score any face |
| duplicate_document | Document already used by another approved customer |
Errors & limits
Errors use standard HTTP status codes and a consistent body:
{ "error": { "code": "return_url_not_allowed", "message": "return_url origin is not in the project's allowed return origins" } }400invalid request,401bad key,403missing scope,404not found402monthly plan limit reached,409idempotency or state conflict,422not allowed by settings429rate limited: 300 requests per minute per key
Send an Idempotency-Key header on create requests. Repeating a request with the same key returns the original response instead of creating a second verification.