Receiving Verification Results: Polling, Webhook or Both

Developer guides

For: Backend developer designing how a verification result reaches the system that makes the decision

Compare polling and webhooks for verification results, using the platform's statuses and events: what each costs, where each fails, and a reconciliation design that uses both.

Your user has finished the widget. Somewhere in your backend a record says "waiting for verification", and the only question that matters now is how that record learns the outcome. You can ask the platform again and again, you can wait for the platform to call you, or you can do both. This guide is for the backend developer who has to choose, and it answers one question: when does each way of receiving a verification result fit, what does each one cost and how does each one fail, and what design keeps a decision correct when the usual path breaks?

The guide is an addition to the verification session lifecycle, not a copy of it. That article follows one session through five stages and covers the content of the status field, the four events, the options of the callback, and the habits of a safe receiver. Here the subject is the delivery decision itself: the trade-offs between asking and being told, the loop that makes the two work together, and the failure cases a team meets in the first month. The statuses and events of the platform are the worked example throughout. Samples use placeholders in angle brackets, which you replace with the values provided with your access.

Four things your system waits for

"The result" is not one event. The platform finishes four different pieces of work, each with its own signal and its own way of being read, and a design that treats them as one will wait forever for the wrong thing.

What finishesThe signal you can wait forThe call that reads itHow you know it is over
A regular verification sessionThe SESSION_COMPLETE event/kyc/session/status, by session identifier or by your client keystatus has left idle and processing, or the session reports expired.
A questionnaire recordThe QUESTIONNAIRE_COMPLETE event, which carries a questionnaire session identifier/kyc/session/status, with that identifier passed as sessionIdstatus is success, which means the form was filled in and committed, and is not a verdict.
The database checks of a session, such as AML screeningThe DATABASE_CHECKS_COMPLETE event/kyc/session/databaseschecksStatus is complete and pending is zero.
The destruction of personal dataThe PD_ERASURE_COMPLETE event, if the erase call could not confirm in timeThe erase call, read through its result fieldresult is erased, the only value that means the destruction is finished.

Three consequences follow, and they shape everything below.

First, the session and its database checks finish at different times. The documentation says database checks run after the session itself finishes, so a session that reads success can still have checks pending. A design that decides on the session status alone decides too early whenever the scenario includes AML screening.

Second, a questionnaire record has no lifetime. The database check documentation says so directly and tells you to wait for the webhook rather than poll to a deadline. A poller with a deadline is wrong for this case by construction.

Third, the two kinds of waiting have different end conditions. A session ends by a final status or by the clock. A check set ends by complete. Your waiting records need a field for what they wait for, not only a field for "pending".

Polling: what you get and what you pay

Polling means that your server calls the platform on a schedule until the answer is final. In this platform the calls are /kyc/session/status for the session and /kyc/session/databases for the checks. Every call is a POST under your API address with a token, and a failure can arrive in several shapes: usually as an HTTP 200 with status of error, but also as a refusal such as 401 or 403 that has no status field. A poller reads both the HTTP code and the body.

What polling is good at

It needs no inbound network path. Polling is an outbound call from your server. Nothing on the internet has to reach your system, which makes polling the only option for an environment that cannot expose an endpoint, and the simplest option for the first working version. The quick start for the first verification says the callback address must be reachable from outside your network, and a laptop or a locked-down segment is not.

It can start from what you already know. The status call accepts your own client key as well as the session identifier, and a client key always resolves a regular session. Because the widget creates the session from the scenario identifier and the encrypted client key, your server knows the client key from the moment it issued it, and may not yet know the session identifier. Polling by client key needs no identifier to be carried over from anywhere. The database check call also accepts the client key and then resolves the latest session with that key, which is convenient and is also a trap if a user has started a second session; use the session identifier as soon as you have it.

Its failure is visible and local. If the poller stops, your own monitoring shows stale records. Nothing depends on the delivery behaviour of a system you do not run.

It is the same code path as the reconciliation loop. Whatever you choose, you will end up with a function that reads a session and applies the result. Polling is that function called by a timer.

What polling costs

Latency is a dial you set against load. A short gap gives a fast reaction and many calls, and a long gap gives a slow reaction and few calls. A session can still answer processing after the user has finished, so the first calls after the widget closes may return it. The documentation names one interval: for the results of database checks, it describes a gap of 5 to 10 seconds as reasonable. For the session status it names none, so choose your own, and treat it as a setting you can change without a release. Lengthen the gap as the record gets older, and give up on a schedule that fits the session lifetime you configured.

Every waiting record is a standing cost. With many users in the middle of a flow, a fixed gap gives a steady stream of calls, nearly all of which return "not yet". The cost is not only your own traffic: the general rule from API security guidance is that a consumer should not create load it does not need. The OWASP list of API risks names unrestricted resource consumption, and its description notes that serving requests takes network, processor and memory resources. A poller that is a good citizen has a ceiling on how many records it polls at once and a back-off that grows.

A busy session answers with a conflict. The response format documentation lists HTTP 409 for a session whose earlier request is still being processed, with the note that nothing of the refused request was applied and the request should be repeated once the other has been answered. A poller treats that as "ask again later", not as an error.

Polling from the browser is the wrong place. The page can poll your own backend, and should for the progress screen. It must not poll the platform with your token. Timers in a page are also not reliable: browsers enforce a minimum delay for timers in inactive tabs, so a user who leaves the tab sees updates late. The reliable poller is on your server, where nobody switches tabs.

A sensible polling schedule

The following is an illustration of the shape, with numbers I chose and not numbers from the platform. Short gaps at first, longer gaps later, and a stop that comes from the session itself.

// Illustrative schedule in seconds. Tune it against your own traffic.
const DELAYS = [5, 10, 20, 30, 60, 120];

function nextDelay(attempt) {
  const base = DELAYS[Math.min(attempt, DELAYS.length - 1)];
  const jitter = Math.random() * base * 0.2;        // spread the load
  return base + jitter;
}

The jitter matters more than it looks. When a deployment restarts, every record that was waiting restarts its schedule at the same moment, and without jitter they all call together.

The stop rule is the part to get right. A session that nobody finishes keeps its stored status of idle, and the session object reports expired once secondsToLive reaches zero. So a poller that waits for the session stops for one of three reasons: the status is final, the status is expired, or the lifetime you configured has passed. A record that has neither a final status nor a deadline polls forever.

That deadline belongs to the capture of the session only. Once a session is terminal, its database checks finish asynchronously afterwards, and the capture lifetime says nothing about them. The documentation ties the lifetime stop rule to an abandoned session, whose stored status stays idle, and an abandoned session is the case in which checksStatus never reaches complete. A completed session that is still waiting for its checks is a different case, so do not stop that wait because the capture lifetime has elapsed. Raise an alarm for a person to look at if the checks stay pending for longer than your own threshold.

Webhooks: what you get and what you pay

A webhook means that the platform calls an address of yours when something completes. You enter the callback URL in the callback settings of the scenario, choose the options, and use the test request to confirm that the address is reachable.

What webhooks are good at

They are fast without being busy. The platform tells you when there is something to know, so the cost of a quiet system is zero calls, and you do not wait for the next polling gap. The documentation does not specify how soon after completion the platform sends an event, so measure it in your own environment and do not plan around a figure.

They cover all four kinds of completion with one mechanism. One endpoint, four event names. For questionnaire records, which have no lifetime to poll against, the webhook is the natural way.

They can keep some identity data out of the path, and the options act differently per event. The callback has an option that omits identity fields, and an option that encrypts the body with the secret key of the scenario. With the omit option on SESSION_COMPLETE, names, document data and images stay out of the callback and the session identifier stays in, together with the faceReference block that reports a required reference comparison, so a design can fetch the result over your own authenticated call. On QUESTIONNAIRE_COMPLETE the entered values (full name, date of birth, email, phone and the values of your own fields) are dropped, while the identifiers, the key and the title of each field stay, and the check results of the questionnaire are sent in full whatever the option. On DATABASE_CHECKS_COMPLETE every check result is sent in full whatever the option. Encryption protects the content of a body but does not make a full payload shorter. So the session event leaves out the documented identity fields and keeps the session identifier and the reference outcome block (an id, a boolean and a code), and the questionnaire and database check events can carry verdicts. Plan the endpoint, its logs and any proxy in front of it for that content, and read receiving webhooks for the exact fields.

They can be limited to finished sessions. A further option sends the notification only when the session is finalized. That cuts the number of events your endpoint has to absorb, and it moves some of the "is this final" logic to the platform. It does not remove the need to read the status, because the rule is to treat the event as a signal.

What webhooks cost

You run a public endpoint. It must answer over HTTPS from the outside, and it is open to anyone who learns the address. The lifecycle article covers how to treat the content of a request as untrusted. The new cost for this comparison is operational: an endpoint is a service with its own uptime, deployments and certificates, and during each of those your system is not told anything.

A missed event does not announce itself. This is the central weakness. When polling fails, you see a stale record. When a webhook is lost, nothing happens, and the record waits for something that will not come. The documentation does not state how often an event may be delivered or how long the platform retries, and a design should not assume a number. So a system that relies on the webhook alone has no way to find out that it missed one, other than a user complaining.

Order and duplicates are yours to handle. An event can arrive twice. The database checks event is sent again whenever the session returns to a fully terminal state, with the full result set each time, so a second delivery can be genuine news and not a duplicate. An event can also arrive before your own database has committed the row that tells you what the session belongs to. Both are handled by reading, not by trusting the order: on each arrival, read the current state for the identifier and apply it.

Testing needs reachability. To receive an event on a development machine you need a tunnel or a staging endpoint, and for each environment its own callback URL in its own scenario.

Side by side

QuestionPollingWebhook
Does it need an inbound path to your system?No.Yes, a public HTTPS address.
How fast is the reaction?As fast as your gap, bounded below by processing time.No wait for the next polling gap. The platform's dispatch timing is not specified.
What does a quiet system cost?A steady stream of calls that return "not yet".Nothing.
What happens when your side is down?Polling resumes and catches up, because the state is on the platform.The event may be lost, and you may never know.
How do you notice a failure?A stale record, visible in your own data.Not visibly, unless you built a check.
What does it need to know up front?A client key or a session identifier.A callback URL in the scenario.
Is the secret exposed?The API token is used on every call, so keep it on the server.The endpoint is open to the internet, so treat every request as untrusted.
Does it cover a record with no lifetime?Poorly: there is no deadline to stop at.Well.
How hard is local development?Easy.Needs a tunnel or a staging address.

Neither column wins. The right-hand column is fast and fragile in a way you cannot see, and the left-hand column is slow in a way you can. The design that follows takes the speed of one and the visibility of the other.

The design that works: tell me fast, then check anyway

The pattern is simple to state. Use the webhook to react quickly. Use polling as the net that catches what the webhook misses. Treat both as ways of triggering the same function, one that reads the platform and applies what it finds.

The worked design below covers regular sessions and their database checks. Questionnaire records and erasures have their own identifiers and end conditions, so they are handled in a separate section after the code.

The record that waits

Keep one row for each verification you are waiting on, with fields that say what it waits for and when it last looked.

FieldWhy it is there
Your raw client keyThe thing your server issued, and the link back to your user.
The session identifier, once knownFilled from the first event or the first read.
What the record waits forOne of: the session, the checks, or nothing.
Whether the scenario has database checksDecides whether a final session moves to the checks or to the decision.
The platform status last readKept separate from your own decision.
Your decisionYour own field, so that a manual change of the platform status never overwrites it.
Issued at and last checked atThe basis for the reconciliation query.
The capture deadlineWhen a record that waits for the session stops being polled. It does not apply to a record that waits for checks.

One function, three triggers

Write a single function, for example refresh(record), which calls the right read for what the record waits for, writes the status it finds, and moves the record on if the state allows. Then call it from three places: the webhook handler, the progress screen of your page (through your own backend, which then calls it at most once in a while), and a periodic job that picks up every record that is still waiting.

const STATUSES = new Set(["idle", "processing", "success", "failed", "exception", "expired", "suspicious"]);
const FINAL = new Set(["success", "failed", "exception", "expired", "suspicious"]);

// api() returns { http, data } and never throws on a refusal (see the first-API-call page for such a helper).
// "retry": try again later. "fix": a defect in the request, the token or the address, so alert and stop.
function classify({ http, data }) {
  if (http === 409 || http >= 500) return "retry";    // busy session, internal error
  if (http >= 400) return "fix";                      // 400 validation, 401 token or IP_MISMATCH, 403 Access Denied
  if (!data) return "fix";                            // not JSON
  if (data.status === "error") return "retry";        // HTTP 200 with an error inside: bounded retries, then alert
  return null;
}

async function refresh(record) {
  if (record.waitingFor === "session") {
    const reply = await api("/kyc/session/status", record.sessionId
      ? { sessionId: record.sessionId }
      : { clientKey: record.rawClientKey });
    const fault = classify(reply);
    if (fault) return onFault(record, fault, reply);
    const s = reply.data;
    if (typeof s.sessionId !== "string" || !STATUSES.has(s.status)) return onFault(record, "fix", reply);
    if (!FINAL.has(s.status)) return db.touch(record.id);

    // An expired session was abandoned: it never reaches "complete", so it must not enter the checks wait.
    const abandoned = s.status === "expired";
    const next = record.expectsChecks && !abandoned ? "checks" : "none";
    const job = next === "checks" ? null
      : { job: abandoned ? "abandoned" : "decide", opId: `${record.id}:${abandoned ? "abandoned" : "decide"}` };

    // One conditional write: it succeeds for exactly one caller, and it stores the next job in the same transaction.
    return db.moveIf(
      record.id,
      { waitingFor: "session" },                                   // only if nobody moved it yet
      { waitingFor: next, sessionId: s.sessionId, platformStatus: s.status },
      job,
    );                                                             // false: another trigger already did this step
  }
  if (record.waitingFor === "checks") {
    const reply = await api("/kyc/session/databases", { sessionId: record.sessionId });
    const fault = classify(reply);
    if (fault) return onFault(record, fault, reply);
    const c = reply.data;
    if (!Array.isArray(c.results) || typeof c.checksStatus !== "string") return onFault(record, "fix", reply);
    if (c.checksStatus !== "complete") return db.touch(record.id);  // no deadline here: see the stop rule
    return db.moveIf(record.id, { waitingFor: "checks" },
      { waitingFor: "none", checks: normalize(c.results) },
      { job: "decide", opId: `${record.id}:decide` });
  }
  if (record.waitingFor === "none" && record.expectsChecks && record.sessionId) {
    return refreshCompleted(record);                               // a repeated DATABASE_CHECKS_COMPLETE lands here
  }
}

// A completed record can still receive a genuine new result set, for example after a re-run of a check.
async function refreshCompleted(record) {
  const reply = await api("/kyc/session/databases", { sessionId: record.sessionId });
  const fault = classify(reply);
  if (fault) return onFault(record, fault, reply);
  const c = reply.data;
  if (!Array.isArray(c.results) || c.checksStatus !== "complete") return false;   // not terminal again yet
  const latest = normalize(c.results);                              // one internal shape for event and poll
  if (sameResults(latest, record.checks)) return false;            // a plain duplicate: nothing to do
  // Replace the stored set by sessionId and open a review. The original decision job is never queued again.
  return db.replaceChecksIf(record.id, record.checks, latest,
    { job: "review-changed-checks", opId: `${record.id}:checks:${digest(latest)}` });
}

Reading twice is harmless. Writing twice is not, unless the write is made safe, and the code above does that in two ways. The transition is conditional: moveIf changes the row only if it still holds the state the caller read, so when the webhook handler and the periodic job reach the same record together, exactly one of them wins and the other does nothing. And the follow-up action is a stored job with a stable operation identifier, written in the same transaction as the transition. The worker that takes the job passes the identifier on to whatever it triggers, such as a payment, a message or a block, so that a repeated delivery of the job is a no-op downstream as well. This follows the HTTP definition of idempotence that the lifecycle article uses: the effect of repeating is the effect of doing it once. The property comes from the conditional write and the stable identifier, and not from the read. Without them, two concurrent calls can both apply the result.

Two details of the code deserve a sentence each. An expired session is resolved as abandoned before any move to the checks wait, because an abandoned session never reaches complete and a wait without a deadline would never end. And a completed record is not frozen: the database checks event is sent again whenever the session returns to a fully terminal state, and a check can be re-run, so a later screening result is genuine news. refreshCompleted reads the current set, compares it with the stored one after both are mapped to one internal shape (normalize, sameResults, digest are your own small helpers), replaces the stored set by sessionId only if it changed, and opens a review job with an operation identifier derived from the new content. It never queues the original decision job again, so a payment or an approval that has already happened is not repeated, and a person decides what a changed result means.

The onFault handler needs one rule per class. For "retry" it schedules a later attempt with a growing gap and counts attempts, and it raises an alert when the count passes your threshold. For "fix" it stops retrying that record, raises an alert at once, and leaves the record waiting so that a person can correct the token, the allowlist or the request and release it.

The webhook handler becomes thin

The handler checks that the body has the shape it expects, stores a job durably (a database row or a queue that survives a restart), and only then answers with a success code. A worker takes the job, finds the identifier in the event, looks up the record, and calls refresh. Answering first and keeping the work in memory loses the event when the process restarts, and the documentation gives no guarantee that the platform will send it again. If the store is unavailable, answer with an error code and not with success, so that the handler never claims to have kept what it did not keep. If the worker finds no record for the identifier it does not guess. It reads the session, which echoes your own client key, and matches that key to a row your server created. A session whose client key your server never issued is not yours, and an event for it is dropped and logged.

This is also how a webhook handles the case where it arrives before the row is updated. Park the event by identifier and try again shortly, or do nothing and let the periodic job pick the record up. The periodic job exists for exactly this.

The periodic job is the safety net

Every minute or so, run a query for records that are still waiting and were last checked longer ago than a threshold that grows with the age of the record, and call refresh for each, with a ceiling on how many in flight. This job is the answer to a lost webhook. It also answers the endpoint that was down during a deployment, the event that arrived while a certificate had expired, and the event that was rejected because your queue was full.

Make the job cheap when the webhook works: a record that an event has already moved is no longer waiting, so the query does not select it, and the job touches only what is still waiting. You get the speed of the push, and the poll runs for the records that need it.

Questionnaire records and erasures sit outside this loop

A questionnaire record is read with the same status call, but only by its questionnaire session identifier. A client key never resolves a questionnaire record. So the loop above cannot find one from the client key alone. The identifier arrives in the QUESTIONNAIRE_COMPLETE event, so store it from the event. If the event is lost, recover it from the list of questionnaire records (/kyc/questionnaire/sessions, newest first, optionally limited to one scenario) and match the record to your user by the data you collected. A record has no lifetime, so give it a slow check and no deadline. success means the form was filled in and committed. It is not a verdict.

An erasure is a request you make, so you hold its answer. The erase call is read through its result field, and erased is the only value that means the destruction is finished, while in_progress means it is not. The documentation says that repeating the call answers the same once the destruction is done, so a lost PD_ERASURE_COMPLETE is recovered by repeating the call, not by polling the session.

The sweep through the filter call

Polling one record at a time answers the question "what happened to this one". A second question matters for an audit: "what exists on the platform that my system does not know about?". The filter call answers it. /kyc/sessions/filter searches by date range, status, client key, scenario and other criteria, and returns pages.

A daily sweep that lists the sessions of the last few days for your scenario and compares them with your own table finds the records your system lost, such as a session whose client key was issued and whose row was rolled back. Four details of this call matter in practice.

  • The date range is a calendar day in UTC, and with only one boundary the window is one day. Do not read a local date into it.
  • The order is not defined unless you send filters.range.sortOrder, so a paged walk without a stated order can show an item twice or miss one when new sessions arrive during the walk.
  • results.pageInfo.total is the number of pages, and results.pageInfo.totalItems is the number of sessions. The names invite a mix-up.
  • Without the permissions to view document text, checks or images, the matching keys arrive as the string Access denied and are not left out. A sweep that compares fields must treat that string as "not visible to this token", not as data.

A worked example: one applicant, start to finish

Take a scenario with a document, a selfie with liveness and an AML database check. The table follows one applicant, and shows which trigger moves the record. The times are in order, without specific numbers.

MomentWhat happensRecord waits forTrigger
Before the widgetYour server issues the client key and stores the raw value with the user.The sessionNone yet. The poller can already ask by client key.
Widget openThe widget creates the session from the scenario identifier and the encrypted client key.The sessionA read by client key returns the session, probably idle or processing.
The user finishes the widgetThe page's success callback runs. It only updates the screen.The sessionThe page asks your own backend for the state.
Soon afterThe SESSION_COMPLETE event reaches your endpoint.The sessionThe handler calls refresh, which reads the status.
Processing is not overThe read returns processing.The sessionThe periodic job comes back later, with a longer gap.
Session finalThe read returns success. On the liveness step, verified and outcome.class are read as well.The checks, if the scenario has anyrefresh moves the record on and starts waiting for the checks.
Checks doneThe DATABASE_CHECKS_COMPLETE event arrives, or a read of the databases call returns complete with no pending checks.Nothingoutcome is read before passed on each check, and the decision is written.

Now change one thing: the SESSION_COMPLETE event is lost. The only difference is that the row stays in "session" for longer than usual. The periodic job reads it by client key at the next turn, sees success, and the record moves on. The user waits a little longer and nobody is paged.

Change a second thing: the user never finishes. No event ever comes. The record stays idle on the platform. The periodic job reads it a few times with growing gaps, then stops at the lifetime deadline you configured, marks the verification as abandoned in your own terms, and offers the user a fresh session. That is a normal outcome, and the design handles it without special code because the stop rule is part of the record.

Reading the answer correctly

Whichever trigger got you here, refresh has to read the answer in the right order. The rules come from the documentation of the session object and the database check results.

  1. Check the HTTP code and the body before you read a session. A refusal such as 401 or 403 has no status field. If the body has status of error, the call failed, whatever the HTTP code says, because a failure normally arrives with a successful HTTP code. Only then check that the body has the shape of a session.
  2. Branch on the session status, never on message text. The message is English prose that is reworded without notice.
  3. Treat suspicious as a route to a person. It is a flag the platform sets, and what the business does with it is a decision of your risk policy.
  4. Treat expired and exception as outcomes with their own policy. A fresh session or a review, not a retry of the same read.
  5. On a liveness step, read verified and outcome.class too. A successful status alone does not establish verified liveness. The documented classes are success, verdict, technical, unverified and exhausted. Decide in your own flow whether an unverified pass is enough.
  6. For database checks, read outcome before passed. A check that never ran arrives with a status of success and a passed of null. Only outcome tells "not checked" from "checked and clean".
  7. Normalize the two shapes of check result. The event sends the verdict fields on the entry of results itself, and the polled call sends them under result. Map both to one internal shape, so that the rest of your code does not care which trigger delivered the data.

Keep the platform's status and your own decision in separate fields. A reviewer can change a session status manually in the dashboard, and a later read will show the changed value; your own decision should not be overwritten by it.

Failure cases and what the loop does with each

A first month in production can meet several of the following. The table says what refresh and the periodic job should do, and the cause as the documentation gives it.

What you seeCause according to the documentationWhat your code does
HTTP 200 with status of errorThe normal shape of a failure.Retry a bounded number of times, then alert.
HTTP 400, status of error, a details listThe body failed validation. Each entry names the field with path, expected and actual.Do not retry. This is a bug in your request. Alert.
HTTP 401 with a missing, expired or unknown tokenThe token is not valid.Do not retry in a loop. Alert, and rotate or fix the token.
HTTP 401 with IP_MISMATCHA token bound to one address is used from another.Do not retry. Check which host is calling.
HTTP 403 with Access Denied and no statusThe caller is outside the IP allowlist.Do not retry. Check the allowlist.
HTTP 409An earlier request for the same session is still being processed. Nothing of the refused request was applied.Retry later.
HTTP 500 with internal errorAn unexpected failure on the platform.Retry with a growing gap, and alert if it continues.
The status call returns processing for a long timeThe session is still being processed.Keep the schedule. Stop at the lifetime deadline.
The event arrives for an identifier you do not knowEither a record is not committed yet, or the session is not yours.Read it. Match its client key to a row you issued. Drop it if none.
The check set stays pendingChecks are still running, or the session is abandoned.If the session was completed, keep waiting and alert a person after your own threshold. Do not stop because the capture lifetime passed. An abandoned session never reaches complete, so a record that never left the capture wait stops at the session lifetime.
No event comes at allA lost delivery, a wrong callback address, or the endpoint was down.The periodic job finds the record.

A note on retrying. The HTTP specification defines an idempotent method as one where the effect of multiple identical requests is the same as the effect of one, and it lists the safe methods and a few others as idempotent. POST is not on that list. So a generic HTTP client or proxy is right not to repeat a POST on its own. The status and database check calls only read, which is what the documentation says about their purpose, so your code can repeat them safely, but you must write that retry yourself. Where a response carries a Retry-After header, the HTTP specification says it tells you how long to wait before the next request, and a client that honours it puts less strain on the server than one that ignores it. Do not retry the calls that change something, such as a status change or an erase, without first reading the state back.

How to choose

The choice depends on four facts about your situation more than on taste.

Your situationA reasonable choice
A prototype, or a system that cannot expose a public endpointPolling only, by client key, from your server.
A product with real traffic and a normal public serviceWebhook for speed, plus the periodic job for the guarantee.
A scenario with database checksPlan for both signals, because the session and the checks finish at different times.
A flow with questionnairesThe webhook is the main signal, because a record has no lifetime to poll against, with a slow periodic job as the net.
An environment where every decision must be explainable laterBoth, with an audit log of each read: the trigger, the identifier, the status read and the decision written.
A very large number of waiting recordsWebhook first, with the periodic job limited by a ceiling on calls and a gap that grows with age.

If you are unsure, build the periodic job first. It is the part you cannot do without, it works in every environment, and it is the same function the webhook handler calls later. Adding the webhook then lowers latency and load without changing correctness, and removing it later would also leave the system correct. That asymmetry is the reason to build in that order.

A test plan for the delivery path

Test the path in which things go wrong, because the path in which they go right will have been tested by the demo.

  1. Both triggers on. Run a session end to end with the webhook and the job active, and confirm that the record moves once and the decision is written once.
  2. Webhook off. Point the callback to an address that does not answer, or leave it empty. Confirm that the job completes the record.
  3. Webhook only, job off. Confirm that the record completes, and note how long a lost event would leave it waiting. That is the exposure you remove with the job.
  4. Duplicate. Send the same event twice to the handler and confirm that nothing is repeated, such as a payment, a message or a block.
  5. Early event. Deliver an event before the row exists. Confirm that it is read, parked or left to the job, and not dropped as foreign.
  6. A foreign identifier. Post an event with an identifier your system never issued. Confirm that it is read, rejected and logged.
  7. Abandonment. Open the widget and close it. Confirm that the record stops at the deadline and a fresh session can be offered.
  8. Checks pending. Use a scenario with a database check, and confirm that the decision waits for complete and reads outcome before passed.
  9. The sweep. Run the filter call for the last day and compare it with your table.
  10. Credentials. Confirm that the API token appears in no log, no page and no URL.

The dashboard helps with the first step. The session log shows sessions as they arrive, and the KYC SDK button opens the widget for a scenario so you can produce a session yourself.

Where this sits in a verification flow

The REST API, the dashboard and the webhooks are the three parts of the platform that this design touches. The REST API reads the session, the checks and the filtered listings. The dashboard shows the session log, reports and export, and manual decisions. The webhooks send the events SESSION_COMPLETE, QUESTIONNAIRE_COMPLETE, DATABASE_CHECKS_COMPLETE and PD_ERASURE_COMPLETE, with body encryption and an option that does not send personal data. The widget that produces the sessions runs in current desktop and mobile browsers in 90+ languages; for the browser side of the integration, read integrating identity verification with a web SDK, step by step. To talk about your own flow, contact the team.

Sources

All sources were fetched on 27 October 2026.

Ready to talk?

Tell us what you are building and we will help you plan the integration.

Talk to us