Documentation

Integrate VerifyKit

Add automated identity verification to any website with one API call and one webhook.

Quickstart

  1. Create an account, then open Settings and add your website's address (e.g. https://shop.example.com) to Allowed return origins.
  2. Create a test API key under API keys. Keep it on your server only.
  3. Add a webhook endpoint under Webhooks and store the signing secret.
  4. When a customer clicks “Verify”, call the API from your backend and redirect them to hosted_url.
  5. 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.

Header
Authorization: Bearer sk_test_xxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Each 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.

FieldDescription
external_referenceRequired. Your ID for the customer, e.g. your user ID.
document_typeOptional. passport, national_id or driving_license. If omitted, the customer chooses from the types enabled in Settings.
country_codeOptional. ISO 3166-1 alpha-2, e.g. BD.
return_urlOptional. Where to send the customer afterwards. Its origin must be allowed in Settings.
metadataOptional. Flat object of strings, numbers or booleans, returned as-is.
Request
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"
  }'
Response · 201
{
  "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:

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

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

Node.js
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);
}
PHP / Laravel
$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

createdCreated by your backend; the customer has not opened the link yet.
pendingThe customer opened the link and is capturing documents.
processingDocuments submitted; the engine is analysing them.
approvedAll checks passed (or a reviewer approved it). Final.
declinedA clear failure such as a face mismatch or expired document (or a reviewer declined it). Final.
review_requiredInconclusive; a person on your team should decide in the dashboard.
expiredThe customer did not finish within 7 days.
cancelledCancelled through the API.
errorAutomated analysis failed repeatedly; review it manually.

Reason codes

The reasons array explains a decision; the first entry is the main reason.

all_checks_passedAll checks passed
face_mismatchSelfie does not match the document photo
face_match_low_confidenceFace match is borderline
face_match_unavailableFace match could not be computed
document_expiredDocument is expired
underageApplicant is under the minimum age
mrz_checksum_failedMRZ check digits do not validate (misread or tampered)
passport_mrz_unreadablePassport MRZ could not be read
national_id_without_mrzNational ID has no readable MRZ
driving_license_needs_reviewDriving licence data was read with OCR only and needs a human check
document_unreadableDocument text could not be read
document_not_recognizedNot a recognised identity document (no readable data and no portrait)
document_number_missingDocument number not found
document_portrait_not_foundNo portrait found on the document
front_back_mismatchThe name on the front does not match the name on the back
document_back_shows_portraitThe back-side photo shows a portrait (the front was likely photographed twice)
document_image_qualityDocument photo quality is poor
date_of_birth_unreadableDate of birth could not be parsed
date_of_birth_missingDate of birth not found
selfie_face_not_foundNo face found in the selfie
selfie_multiple_facesMultiple faces in the selfie
liveness_failedHead-movement liveness check failed
liveness_frames_missingLiveness frames missing a face
liveness_face_changedA different person appeared during the live check
liveness_frames_inconsistentLiveness frames are inconsistent with the selfie
liveness_spoof_detectedPresentation attack detected (screen, replayed video, print or mask)
liveness_spoof_suspectedPossible presentation attack (screen, replay or print); check the frames
liveness_spoof_unavailableAnti-spoof check could not score any face
duplicate_documentDocument already used by another approved customer

Errors & limits

Errors use standard HTTP status codes and a consistent body:

Error
{ "error": { "code": "return_url_not_allowed", "message": "return_url origin is not in the project's allowed return origins" } }
  • 400 invalid request, 401 bad key, 403 missing scope, 404 not found
  • 402 monthly plan limit reached, 409 idempotency or state conflict, 422 not allowed by settings
  • 429 rate 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.