Docs contentsYaliyomo ya nyaraka

Verify on your backend

What your server should check before it trusts a digid.kyc/1 document, and what each check does and does not prove.

The SDK does its checks on the phone. Your backend makes the decision. Treat every document as evidence from a device, check it on the server, and only then act on it.

How the document reaches you

digid sends the document nowhere. Your app sends it to your backend, over your own connection:

  • use TLS, and your app's own authentication (your user's session or token), so you know which of your users sent it;
  • send the document text exactly as the SDK produced it, without parsing and re-serialising it;
  • do not log it: it holds personal data and, with fingerprints, biometric images.

The checks, in order

1. Schema

schema must be digid.kyc/1. New optional fields can appear in version 1 at any time, so ignore fields you do not know. A breaking change would be digid.kyc/2.

2. The tamper check

The integrity block lets your backend detect a document that changed after the SDK built it: an edited name, a verdict flipped from fail to pass, a truncated upload. Run the tamper check on every document and refuse any that fails it.

  • Flutter: DigidPayload.verify(json) returns true or false.
  • Web: engine.verifyPayload(json).
  • Your own server language: recompute it from the recipe in the full field reference, available to customers who sign in.

What it does not prove: who built the document. Anyone with the SDK can build a document that passes it. That is why step 1 above (your authenticated connection) matters, and why the remaining checks are on the content.

3. The licence

"licence": { "state": "licensed", "evaluation": false }
  • In production, refuse documents with evaluation: true. They come from an SDK without a valid licence: they are watermarked and never better than review.
  • state is licensed, grace (the licence has expired but is in its grace period; renew it) or evaluation.
  • The document records the licence's state, not its id. The engine result your app receives (licence.licence_id, licence.org_name) carries the id: if you want it on the server, send it alongside the document and compare it with the licence ids in your customer portal.

4. Where it came from

sdk.platform names the platform that built the document (android, ios, web) and sdk.api_version the engine version. Check the platform matches the client that sent it, and record both for your audit trail.

5. Freshness

created_at is when the capture finished, from the device clock (UTC). Refuse documents older than your session window (a few minutes is typical), and refuse a document you have already accepted once, for example by keeping a hash of accepted documents for that window. This stops a captured document being replayed.

6. The document type

document.type is nida_id or tz_driving_licence. Refuse types your product does not accept, whatever the flow's own documents setting said.

7. The verdict

document.overall is the engine's verdict over every check and, when a selfie ran, the face match and the liveness check:

overall Meaning Typical action
pass Every check passed, the face matched and liveness passed, where the flow had them Continue
review Nothing failed outright, but something is unverified or uncertain Send to a person
fail A check failed, or the face did not match Refuse, or ask to try again

document.checks lists each check with its status and a short detail, so a reviewer can see why. The ids are in Check codes.

8. Expiry

An expired card is handled by the expiry_policy the app's engine used: review (the default in the ready-made flow) sends it to review, reject fails it, ignore leaves it out of the verdict. Your backend should not rely on the device's setting: apply your own policy to subject.expiry_date (and, for a licence, document.driving_licence.expiry_date) against your own clock.

9. The face

When your process needs a selfie, require all of:

  • face is not null (a null face means no selfie step ran);
  • face.verdict is match (uncertain and no_match are not);
  • face.liveness.passed is true.

face.score and face.threshold are recorded for your audit trail. Do not apply your own cut-off to them; face.verdict already does.

10. The person

subject holds what the card says. Compare it with what your user told you (name, date of birth), and if your process requires it, confirm the NIN with NIDA from your backend. digid does not contact NIDA.

A sketch

// Node.js, after your own authentication of the request
const doc = JSON.parse(body);

if (doc.schema !== "digid.kyc/1") return refuse("unknown schema");
if (!tamperCheckPasses(body)) return refuse("document changed");   // see the full field reference
if (doc.licence?.evaluation) return refuse("evaluation document");
if (Date.now() - Date.parse(doc.created_at) > 5 * 60_000) return refuse("too old");
if (await seenBefore(body)) return refuse("replayed");
if (!["nida_id"].includes(doc.document?.type)) return refuse("document not accepted");
if (isExpired(doc.subject?.expiry_date)) return review("expired card");

if (needsSelfie && !(doc.face?.verdict === "match" && doc.face?.liveness?.passed)) {
  return doc.document.overall === "fail" ? refuse("face") : review("face");
}

switch (doc.document.overall) {
  case "pass": return accept(doc.subject);
  case "review": return review("engine");
  default: return refuse("checks failed");
}

Native Android and iOS

The native SDKs hand your app the engine result JSON rather than the digid.kyc/1 document. It carries the same verdict (overall), checks, card fields, face match and liveness, plus the licence id. Apply the same checks to it, except the tamper check and created_at, which only the document has: record your own receive time instead.