TrustWixDocs
Handle results

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

Sending the address at create time
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:

409 Conflict
{
  "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:

409 Conflict
{
  "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
  }'
Response
{
  "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.

On this page