back to overview
For developers·6 min read

Managing 2FA/OTP latency in visa portals

The hardest part of visa portal automation isn't login — it's the OTP round trip. Here's how to architect it so you don't lose slots waiting on an applicant's inbox.

Why OTP latency is the real blocker

Most engineers assume the hard part of booking a visa slot is solving captchas or rotating proxies. Those problems are solvable with off-the-shelf tools. The problem that actually kills automations is time: once you hold a slot, the portal gives you 5–15 minutes to confirm, and somewhere in that window it will send your applicant a fresh code that they (not you) must receive and relay back.

The naive implementation — poll the applicant's mailbox and regex for a code — breaks for three reasons. Email delivery adds 10–90 seconds of unpredictable latency. SMS delivery can stall for minutes on some carriers. And asking for mailbox credentials violates every data-handling policy you'll ever want to pass an audit against.

The pattern

Treat OTP as a stateful interrupt

In Opaige, the worker's state machine has two dedicated states: AUTH_OTP_REQUIRED (code sent during portal login) and CONFIRM_OTP_REQUIRED (code sent during slot confirmation). When the adapter returns either error code, the booking processor suspends the run and opens a waitForOTP(runId, expiresAfterMs) promise against a Redis-backed manager.

At the same moment:

  • A socket event fires to the applicant's dashboard with the context and expiry.
  • An email goes to the applicant as a backup channel (subject line tells them the code is already in their portal inbox — don't confuse them).
  • The operator console receives the pending OTP on /operator/pending-otps so a human can submit it if the applicant is unreachable.

Whoever submits first wins. The submit route calls otpManager.receiveOTP(runId, code), which resolves the pending promise in the worker. The state machine advances, the adapter types the code into the real portal form, and the booking continues — all without the worker ever exiting memory.

Timing

Budgets that actually hold

VFS Global's confirm-OTP window is ~10 minutes. TLS Contact's is ~8. We treat the first 90 seconds as "free" — the code usually arrives within that budget and most applicants see the dashboard prompt the moment their phone buzzes.

After 90 seconds, we send a reminder ping (another socket emit + an SMS if provisioned). After 4 minutes with no submission, the operator console escalates — a human can either grab the code from the applicant by phone or mark the booking for retry. After the full portal expiry, the worker re-enters the booking lane and starts over; state isn't lost because BullMQ + Postgres hold the canonical record.

Auth OTP vs confirm OTP

Why they need separate handling

An OTP during login (AUTH_OTP_REQUIRED) is recoverable — if the applicant misses it, you retry the login later and the portal issues a fresh code. An OTP during slot confirmation (CONFIRM_OTP_REQUIRED) is not — missing it means the slot releases back to the pool and you start the hunt again.

Our processor uses the same handleOTP() helper for both, but with different timeout budgets and different downstream transitions. Confirm-OTP timeouts route to RETRY_SCHEDULED with a fresh slot hunt, not a straight FAILED.

For integrators

What your UI needs to do

  1. Subscribe to the socket. Opaige exposes a booking:update channel per user. When state is AUTH_OTP_REQUIRED or CONFIRM_OTP_REQUIRED, render a prominent input — not a modal buried three clicks deep.
  2. Submit to /otp/submit. Pass executionRunId and the code. 2xx means resumed. 400 means no pending OTP (likely already submitted). Don't retry on 4xx.
  3. Show the expiry. The expiresAt in the event payload is authoritative — show a live countdown so applicants don't accidentally let a slot release while they dig for the code.
  4. Fallback to the dashboard email. Not every applicant leaves your app open. The OTP email from Opaige links directly to the booking detail page — build that deep-link so one tap opens your UI with the input focused.