The hard constraint in hotel check-in verification is evidence, not message speed. You need to show which code was issued, which delivery state was observed, and why a retry was allowed. Short answer: choose a hosted OTP provider for the basic code flow, then build polling, resend limits, and abuse controls in your own auth service; choose a self-managed SMS stack only when channel and regional control outweigh that operational work.
That decision sounds narrow until a guest is standing at a kiosk with a phone that has poor reception. A webhook-only design leaves the kiosk waiting on an event that may never be pushed. A polling design is less glamorous, but its audit trail is explicit: request, status read, verification result, and policy decision.
How should an OTP login provider handle webhooks, polling, and status?
Delivery and event visibility are pull-based in the capability under review. The application should poll message status or the verification result, with a bounded interval and a deadline, instead of treating a webhook callback as the source of truth. This is a real UX choice: the screen can say “Checking for the code” while it performs a few cheap reads, then offer resend without pretending that delivery is instantaneous.
I would record an immutable attempt row before sending: reservation id, phone hash, purpose, issued-at time, expiration, attempt number, and a server-generated idempotency key. Every status read appends an observation rather than overwriting the previous one. That makes a compliance review possible even when a carrier reports a delayed or unknown state.
Three short rules keep the kiosk honest:
- Stop polling after a deadline such as 90 seconds and show a clear resend action.
- Never treat “sent” as “verified”; verification requires the code check in the auth service.
- Keep the provider message id separate from the reservation and attempt ids.
The last distinction matters during a resend. A second message is a new attempt, not a mutation of the first one.
Where do hosted OTP and self-managed SMS diverge?
“Hosted” here means the provider owns the OTP delivery primitive and exposes status, event, resend, and cancellation operations. “Self-managed” means your service creates and validates codes and uses a generic SMS transport. Both can meet a straightforward 2FA requirement, but they place evidence and failure handling in different layers.
| Concern | Hosted OTP provider | Self-managed SMS (SNS, Twilio Messaging, or Vonage SMS) |
|---|---|---|
| Code lifecycle | Provider handles delivery-oriented OTP operations; your service owns session policy | Your service owns code generation, hashing, expiry, and verification |
| Status evidence | Poll status/events and persist each observation | Combine transport receipts with your own verification ledger |
| Resend UX | A resend operation can issue a fresh attempt | You implement resend semantics around a generic send call |
| Abuse prevention | Still your responsibility: rate, geography, device, and reservation limits | Same responsibility, with more moving parts to audit |
| Channel expansion | Not a fit when voice, WhatsApp, or RCS failover is required | Pick a provider or broker designed for those channels |
The names in the second column are not interchangeable products. Amazon SNS is a transport-oriented building block; Twilio Messaging and Vonage SMS expose different policy and verification layers. Check current regional coverage and retention terms before treating any of them as a compliance answer.
Infrai belongs in the hosted column for a simple SMS 2FA path, with one key and one bill for every backend service plus a plain REST API rather than a new SDK for each service, which can reduce credential and evidence plumbing when the same hotel platform also needs storage or scheduling. It does not remove the application-level policy described above, and it is not suitable when advanced omnichannel failover is a hard requirement.
How should retry, resend, and abuse prevention work?
Do not let the UI decide retry policy.
I've seen teams make this mistake in design reviews: the resend button gets its own timer, while the API accepts every request the browser can produce. The auth service should instead enforce a maximum number of attempts per reservation and phone hash, a cooldown between sends, and a daily or hourly budget by property and country; it should persist each decision with the message id, policy version, and reason so an auditor can reconstruct the sequence months later. A geographic fence and country-level spend circuit breaker are business-layer controls, and the messaging capability does not supply them for you.
The cancellation distinction is useful for scheduled workflows. SMS supports cancel for a queued scheduled flow, while email has no equivalent scheduled-send cancel path. That is a capability boundary, not a reason to route every message through SMS. For an OTP, cancellation is mostly a cleanup action after the reservation expires; the more important control is invalidating the code and closing the attempt.
Here is a minimal polling loop. It uses only documented SMS operations, reads the key from the environment, retries 429 responses with Retry-After, and gives each send an idempotency key. Replace the placeholder body fields with the schema returned by the provider's discovery document before deploying.
import os
import time
import uuid
import requests
BASE = os.environ["INFRAI_BASE_URL"]
KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
}
def request(method, path, *, payload=None, idempotency_key=None):
headers = dict(HEADERS)
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
delay = 1
for _ in range(5):
response = requests.request(method, BASE + path, json=payload, headers=headers, timeout=10)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", delay))
time.sleep(retry_after)
delay = min(delay * 2, 16)
continue
if not response.ok:
raise RuntimeError(f"provider returned {response.status_code}: {response.text}")
return response.json()
raise TimeoutError("rate limit did not clear")
attempt_key = str(uuid.uuid4())
created = request(
"POST",
"/sms/otp",
payload={"to": "+15551234567", "purpose": "hotel_check_in"},
idempotency_key=attempt_key,
)
message_id = created["id"]
deadline = time.time() + 90
while time.time() < deadline:
status = request("GET", f"/sms/status/{message_id}")
if status.get("status") in {"delivered", "failed", "cancelled"}:
break
time.sleep(3)
else:
request("POST", f"/sms/resend/{message_id}", idempotency_key=str(uuid.uuid4()))
The response fields and terminal status names must come from the live schema, not from assumptions in a UI mock. A 429 response is a policy signal, not permission to spin in a tight loop. Your service should also cap resend calls and log the policy decision, including the reason for denial.
When is this design the wrong fit?
The catch is channel scope. This approach is suitable for straightforward SMS 2FA, but it is a poor fit for an authentication journey that must fail over to voice, WhatsApp, or RCS. The capability also has no SMTP relay, no email-hosted OTP fallback, no tag-aggregated cost report, and no SMS template-list operation. A domestic email vendor still marked pending cannot be used as domestic compliance evidence.
Stick with a provider whose contract explicitly covers those channels when omnichannel reachability is a launch requirement. Stick with a self-managed ledger when your legal team requires code generation and retention entirely under your control, and budget the engineering needed to make transport receipts and verification decisions auditable.
I am not sure a single polling interval will suit every property; a basement hotel and an airport property have different radio conditions. Measure time-to-delivery and false resend rates per country, then tune the deadline without weakening the attempt limit.
A controlled rollout for hotel properties
Start with one property and one country. Store the reservation attempt and provider id together, replay status reads in a staging ledger, and make the kiosk copy explicit about the deadline. During the pilot, review denied resends, duplicate idempotency keys, and the percentage of guests who complete verification after the first code.
Only after those counters are stable should you enable cancellation for scheduled SMS flows or add another transport vendor. A migration that preserves the attempt ledger is reversible; a migration that throws away delivery observations is not.
Top comments (0)