Check existing applicants
Reconciling a list of your own users against the verifications you already hold
You have a list of email addresses from your own database and you want to know which of those people have verified with you. One call answers up to a thousand of them.
curl https://api.trustwix.com/v1/verifications/lookup \
-H "Authorization: Bearer TWK_..." \
-H "Content-Type: application/json" \
-d '{
"emails": ["ana@example.com", "bo@example.com", "cai@example.com"]
}'{
"object": "list",
"mode": "live",
"data": [
{
"object": "verification_lookup",
"email": "ana@example.com",
"found": true,
"attempts": 2,
"ever_approved": true,
"first_attempt_at": "2026-03-01T10:00:00.000Z",
"latest": {
"id": "vs_2f8a1c9b4e7d",
"status": "rejected",
"verdict": "reject",
"score": 0.41,
"reason_codes": ["SELFIE_MISMATCH"],
"flow": "flw_id_and_liveness",
"mode": "live",
"created_at": "2026-07-01T10:00:00.000Z",
"updated_at": "2026-07-01T10:04:12.000Z",
"expires_at": null
}
},
{
"object": "verification_lookup",
"email": "bo@example.com",
"found": false,
"attempts": 0,
"ever_approved": false,
"first_attempt_at": null,
"latest": null
}
]
}You get exactly one result per entry, in the order you sent them, echoed back verbatim. Nothing is
collapsed or reordered, so data[i] always answers emails[i] and you can map the response onto
your own list by index without normalising anything first. Send the same address twice and you get
two identical rows.
Matching ignores case and surrounding whitespace, so Ana@Example.COM and ana@example.com are
the same person and will return the same verification.
Read ever_approved, not just latest.status
latest is the most recent attempt. ever_approved looks across every attempt this address has.
Ana above is the case that catches people out. She passed in March, tried again in July after
changing her name, and that second attempt was rejected. Her latest.status is rejected and her
ever_approved is true. Whether she should keep her access depends on your own policy, but you
have to see both numbers to make that decision, and a reconciliation script that reads only
latest.status will lock out every customer who ever re-verified and failed.
attempts gives you the rest of the picture. A high count against ever_approved: false is somebody
struggling with the flow, and it is worth a look at whether the flow itself is the problem.
What found: false actually tells you
It tells you we hold no verification carrying that address in this environment, right now. It does not tell you the person never verified. Three quite different situations produce the same answer.
They never started one. The straightforward case, and usually the one you are looking for.
We were never told their address. This is the one to check first if a reconciliation run comes
back almost entirely empty. An address reaches a verification in exactly two ways: you send
applicant.email when you create it, or the applicant completes an email step in the hosted flow. If
you do neither, we hold sessions that ran perfectly and completed normally, with no address on them,
and every lookup you run will miss all of them.
curl https://api.trustwix.com/v1/verifications \
-H "Authorization: Bearer TWK_..." \
-H "Content-Type: application/json" \
-d '{
"flow": "flw_id_and_liveness",
"applicant": { "reference_id": "your-user-123", "email": "ana@example.com" }
}'Their retention window has elapsed. When a verification passes out of retention we destroy the stored identity and the search fingerprint in the same statement. A record we have promised to forget cannot still answer "yes, that person verified with us", so it stops being findable at the same moment it stops existing. This is the retention promise working rather than a gap to route around.
Do not treat `found: false` as permission to delete
If your own records say someone verified and a lookup says found: false, the most likely
explanations are the second and third above, not that your records are wrong. Reconcile in the
direction of adding what you are missing, never of deleting what you already hold.
Sandbox and live are separate
A lookup answers within the environment of the key that made it. A live key never sees sandbox
sessions, and the reverse. The mode on the envelope tells you which environment answered, so every
found: false in a response means "not in this one".
If you tested with real colleagues' addresses in sandbox and then look them up with a live key, they come back as not found. That is correct.
Checking one address
The single-address form is a filter on the list endpoint, and it returns the full verification resource rather than a summary.
curl "https://api.trustwix.com/v1/verifications?email=ana%40example.com" \
-H "Authorization: Bearer TWK_..."Same exact matching, newest first, and the usual cursor pagination if that address has more verifications than fit on a page. Prefer the batch endpoint for more than a handful: it keeps the addresses out of the URL and costs you far less of your rate limit.
A request body is not automatically private
Keeping addresses out of the URL keeps them out of proxy access logs, browser history and
Referer headers. It does not keep them out of anything that logs request bodies. Redact these
payloads in your own application logs, tracing and error reporting.
Limits, and what a run of 3,000 looks like
One call carries up to 1,000 addresses. Larger lists are more than one call.
The rate limit charges by size rather than by call, at one token per 50 addresses out of your budget of 100 per minute. A full 1,000-address call costs 20 tokens, so a 3,000-address reconciliation is three calls costing 60 and clears comfortably inside a single minute.
Permissions
verification:read, which is on every key by default. The response carries no applicant identity: no
name, no date of birth, no document number, no images. It answers only the question you already knew
the answer to, since you supplied the address. To read who somebody is, use
applicant data on a single verification, which needs the
applicant:read scope.
When the answer is unavailable
If a lookup cannot be answered you get a 503 with the code LOOKUP_UNAVAILABLE, never an empty
result. The distinction is deliberate and it matters: an empty result and an unanswered question look
identical to a script, and quietly treating one as the other means re-onboarding thousands of
customers who were already approved. Retry with backoff, and treat the addresses in that call as
unknown rather than as unverified.
We refuse the repeat for you
Everything above assumes you check before you create. You do not have to: we refuse the second verification ourselves.
This is on for every account, and it applies per flow. An approval refuses another verification of the same person on the same flow, so a second business running under the same account is unaffected. An owner can widen it, narrow it to particular flows, or turn it off entirely under Settings, and the rest of this section is what to check before you decide.
If your integration creates a verification every time somebody signs in, this changes what those
calls return. That pattern is the one this exists to stop paying for twice, and the create call
that used to hand back a fresh session now answers 409 with the code ALREADY_VERIFIED. No session
is made and nothing is charged:
{
"error": {
"type": "invalid_request_error",
"code": "ALREADY_VERIFIED",
"message": "This applicant has already been verified...",
"request_id": "req_...",
"detail": {
"verification_id": "vs_2f8a1c9b4e7d",
"status": "approved",
"verdict": "approve",
"matched_on": "reference",
"approval_valid_until": "2029-03-04T23:59:59.999Z"
}
}
}Read detail.verification_id and reconcile against it. Do not retry: a retry succeeds only once the
approval lapses, which may be a year away. You will also receive
verification.already_verified so a back office that
never saw the failed call still learns what happened.
What we match on
We match your own applicant.reference_id first, then applicant.email, then the numbers on the
document the applicant presents. The first two are the ones that save you money, because they refuse
before a session exists, and they only work if you send them. A create call that carries neither
identifier cannot be checked at create time, so send reference_id if you send nothing else.
The document match is the one that holds regardless. It runs the moment we read a document, before
any selfie, liveness or screening work, and it closes the session with status: "expired" carrying
the reason code ALREADY_VERIFIED. That status is not a judgement about the person, it means this
session produced no new verification. The applicant is told they are already verified and sent back
to you.
The number is not always readable from the first photograph. When it only becomes known later, because the applicant typed it at the confirm step or because our own reading after submit found it, the same match runs again at that point and closes the session the same way. However late the number arrives, it is checked before a verdict is written, so a document we have already approved on a flow cannot be approved on it a second time.
The document match also compares the national id printed on the document, where it carries one. A
person approved on their ID card who comes back with a driving licence or a passport presents a
different document number, but the same national id, so the two still match and matched_on is
document. Getting a new document does not make somebody a new person.
Matching never crosses accounts. An approval with another business says nothing about yours.
While the person is waiting on review
An approval is not the only thing that refuses a new verification. While the same person has a verification waiting on manual review, a new one is refused too, matched in exactly the same way and governed by the same setting. Otherwise somebody left waiting in the queue can start again, be approved on the second attempt, and then be approved a second time when a reviewer reaches the first case.
At create time you get a 409 with the code VERIFICATION_IN_REVIEW. No session is made and nothing
is charged:
{
"error": {
"type": "invalid_request_error",
"code": "VERIFICATION_IN_REVIEW",
"message": "This applicant already has a verification waiting on manual review...",
"request_id": "req_...",
"detail": {
"verification_id": "vs_7c1d0e4a9b3f",
"matched_on": "reference"
}
}
}detail.verification_id is the case that is still open. Wait for its outcome, which arrives on that
verification as usual when a reviewer decides it. Do not retry the create call in a loop: it keeps
being refused until the review is decided.
When the match is only made from the document, the session you created is closed with
status: "expired" and the reason code REVIEW_IN_PROGRESS. You receive verification.completed and verification.expired for it, as for any session
that ends without a verdict, and no verification.already_verified, because nobody has been approved.
The applicant is shown the same "your verification is being reviewed" screen they saw the first time,
and nothing else about the case.
A retryable rejection is the one exception. When a check fails in a way the applicant is invited to try again, for example a selfie that was too dark, the attempt can sit in the queue while they do. That attempt never refuses the retry, because the retry is exactly what they were told to do.
An approval closes the person's other open cases
Two verifications for one person can still both be open at once, most often because they were started in the same moment, or because one was a retry after a retryable rejection. When one of them is approved, whether automatically or by a reviewer, the person's other open cases leave the queue on their own. Nobody has to decide them, and nobody can approve the same person twice by working through the backlog.
Each of those verifications moves to status: "expired" with the reason code ALREADY_VERIFIED added
to the codes it already carried. No decision is
recorded on the case and the applicant is not emailed about it. You receive
verification.already_verified with gate: "superseded"
naming the approval, then verification.completed and verification.expired.
A case somebody has escalated to a colleague is left alone, because a person is waiting on an answer there.
How wide one approval reaches
By default an approval refuses repeats of the same flow only. If you run several businesses under one account, an approval from your lending onboarding says nothing about your shop's age gate, which is almost always what you want. An owner can widen it so any approval refuses any repeat.
When the refusal stops
An approval does not refuse forever. It stops when the document it was based on expires, and you can also set a re-verification period, whichever comes first. Past that point the applicant simply verifies again with nobody's involvement, which is the intended path for a customer whose passport has run out.
For anything a date cannot express, you lift the refusal on that one approval yourself, either from your backend or from the verification's page in the console. Doing so never changes the original approval: it stands until a new one replaces it, so a customer who never comes back is not left un-verified by an administrative action.
Letting one person verify again
curl -X POST https://api.trustwix.com/v1/verifications/vs_2f8a1c9b4e7d/reverification \
-H "Authorization: Bearer TWK_..." \
-H "Content-Type: application/json" \
-d '{
"reason": "document_expired",
"note": "customer renewed their passport",
"expires_in_days": 7
}'{
"object": "reverification_grant",
"id": "rvg_8c1d4a9f2e7b3016d5a8c2f4",
"verification": "vs_2f8a1c9b4e7d",
"mode": "live",
"reason": "document_expired",
"note": "customer renewed their passport",
"expires_at": "2026-10-04T09:12:44.000Z"
}Your next create call for that person succeeds, and the verification it starts runs your flow in full. Nothing is waived: they present a document and a face again exactly as a new applicant would.
Every field in the body is optional, so an empty body is a valid call. reason defaults to
merchant_request and is one of document_expiring, document_expired, details_changed,
periodic_recheck, suspected_fraud, merchant_request or other; other requires a note.
expires_in_days defaults to 7 and is capped at 30, so a permission you open and forget closes
itself.
The permission is spent by the next verification it allows, and it is one at a time. Calling
this twice returns the same permission with 200 instead of opening a second, which makes it safe to
retry without an Idempotency-Key. Once it has been used, the approval refuses repeats again, and a
person who needs a third run needs another call.
To withdraw one nobody has used yet, DELETE the same path. A permission that has already been spent
cannot be withdrawn, because the verification it allowed is the record of it.
curl -X DELETE https://api.trustwix.com/v1/verifications/vs_2f8a1c9b4e7d/reverification \
-H "Authorization: Bearer TWK_..."Both calls take the verification:create permission, the same one that creates a verification, so
the key your backend already uses needs nothing added to it. A verification that is not approved
answers 409 VERIFICATION_NOT_APPROVED, because it is not refusing anybody and there is nothing to
lift. Both calls are recorded in your account's audit log under the acting key's name, beside the ones
your own team clicks in the console.
Turning it off
Under Settings, "Verifying the same person twice" holds the whole control. Switching "Refuse repeat verifications" off restores the old behaviour exactly: every create call succeeds, a person waiting on review is not refused either, an approval leaves their other open cases in the queue, and you go back to checking with the lookup above if you want to.
A single flow can be exempted on its own if only part of your integration needs to re-run people, in which case the flow's own setting replaces the account's rather than merging with it.
Turning it off is immediate and needs no deploy on your side.