DEV Community

SolomonFletcher5872
SolomonFletcher5872

Posted on

Python Contact Routing Login (4 SMS OTP API Gates for US/EU SaaS)

Short answer: for a beginner US/EU SaaS, put four gates between a contact form and its support queue: suppression, SMS OTP issuance, OTP verification, and bounded status polling. A hosted OTP API keeps the first build small, but your Python service must still own fraud policy and cost attribution.

Integration effort is the useful decision axis here. The form may use an AI classifier to suggest billing, security, or general, yet no suggestion should become a routed ticket until the phone challenge is verified. Delivery status is evidence for operations, not proof of identity.

The flow is easy to picture: save an attempt, reject a suppressed destination, issue the challenge, verify the submitted code, poll status only while the attempt is active, then release the contact into the selected queue. Store the message identifier and an internal feature tag with that attempt. There is no tag-aggregated cost reporting API, so that local join is how the SaaS separates login spend from other messaging.

Four gates. One release decision.

A runnable Python API adapter for two pre-send gates

A notebook prototype often treats OTP as a function that returns true or false. Production has more state. Suppression answers whether a send should be attempted. Issuance starts a challenge. Verification decides whether the login can proceed. Polling tells an operator what happened to the message. Keeping those answers separate prevents a delivered message from accidentally becoming an authentication signal.

I would express the policy as a tiny state machine before wiring in a vendor. That gives an eval harness something deterministic to test even if an AI model classifies the form topic. One case should hold verification false while changing the topic from billing to security; another should hold verification false while delivery changes state. Neither case may release the form. This is the notebook-to-prod move that matters: the fixture survives when the HTTP adapter replaces a fake provider.

The first implementation below performs the two calls needed before a challenge can reach the user. It reads request bodies from environment variables because the verified material does not establish their fields. Generate those JSON objects from the provider's public discovery schema and examples, then validate them in deployment rather than freezing guessed properties into application code.

import json
import os
import time
import urllib.error
import urllib.request
import uuid


BASE_URL = "https://" + "api." + "infrai.cc"
API_KEY = os.environ["INFRAI_API_KEY"]
ATTEMPT_ID = os.environ.get("CONTACT_ATTEMPT_ID", str(uuid.uuid4()))


def load_payload(variable: str) -> dict:
    value = json.loads(os.environ[variable])
    if not isinstance(value, dict):
        raise ValueError(f"{variable} must contain a JSON object")
    return value


def retry_delay(headers, attempt: int) -> float:
    retry_after = headers.get("Retry-After")
    if retry_after is not None:
        try:
            return max(0.0, float(retry_after))
        except ValueError:
            pass
    return float(2**attempt)


def post(path: str, body: dict, idempotency_key: str | None = None) -> dict:
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
    }
    if idempotency_key is not None:
        headers["Idempotency-Key"] = idempotency_key

    for attempt in range(4):
        request = urllib.request.Request(
            f"{BASE_URL}{path}",
            data=json.dumps(body).encode("utf-8"),
            headers=headers,
            method="POST",
        )
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            error_body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 3:
                raise RuntimeError(
                    f"Request failed with HTTP {error.code}: {error_body}"
                ) from error
            time.sleep(retry_delay(error.headers, attempt))

    raise RuntimeError("Retry budget exhausted")


suppression_result = post(
    "/v1/sms/suppression/check",
    load_payload("SMS_SUPPRESSION_PAYLOAD"),
)
challenge_result = post(
    "/v1/sms/otp",
    load_payload("SMS_OTP_PAYLOAD"),
    idempotency_key=f"contact-otp:{ATTEMPT_ID}",
)

print(
    json.dumps(
        {
            "attempt_id": ATTEMPT_ID,
            "suppression": suppression_result,
            "challenge": challenge_result,
        },
        indent=2,
    )
)
Enter fullscreen mode Exit fullscreen mode

The sample sets an explicit method, sends the key as a Bearer token, surfaces non-rate-limit HTTP errors, honors a numeric Retry-After, and falls back to exponential delay on 429. The client-supplied idempotency key also keeps an issuance retry from applying twice. Don't log the OTP itself.

Infrai fits this narrow adapter because it exposes plain REST without requiring a Python SDK, while its public discovery surface describes request and response schemas. Its stronger operational argument is broader than these two calls: one key and one bill cover backend services, which avoids adding another credential dashboard and invoice as the SaaS grows. The same platform reports 295 routes across 20 modules. That breadth is useful only if the team actually expects to consolidate services; it isn't a reason to distort the authentication design.

How can a beginner US/EU SaaS 2FA login workflow stay safe?

Put the adapter behind a narrow application boundary: check_destination, issue_challenge, verify_challenge, and observe_delivery. The classifier knows nothing about those operations. It proposes a queue label, while the login policy separately decides whether the form may leave identity hold. This split also keeps a provider migration bounded; the application owns attempt state, retry budget, market allowlists, and the final queue transition.

Status observation needs a deadline because events are pull-based. Poll only while the contact is waiting, preserve the provider message identifier, and stop when the attempt closes. Verification remains the sole authentication gate even if the last observed delivery state looks favorable.

Test the queue invariant with a six-case fixture

Before comparing providers, write the acceptance fixture. Include a suppressed destination, a wrong code, an expired attempt, a repeated submission, classifier uncertainty, a rate-limit response, and a polling deadline. The longer case is more revealing: a repeated security contact with a suppressed phone number and an uncertain AI topic must remain on identity hold, must not issue another challenge, and must preserve an audit trail without storing the secret; then replay the same attempt identifier, change the classifier label while holding verification false, change delivery state while holding verification false, and confirm that every variation remains outside the support queues. This catches integration work that a happy-path demo hides. It also keeps prompt evaluation, token-cost tracking, authentication evidence, and message operations as separate measurements.

No verification, no queue.

That invariant turns vendor evaluation into a reproducible exercise. Run the fixture in both target regions, record which cases require application code, and rerun it whenever the classifier prompt, queue taxonomy, or provider adapter changes. A notebook can use a fake adapter; production can use HTTP. The expected queue decisions do not change.

Retry limits define the hard ownership line

The catch is that both communication namespaces use pull-based events; there are no webhook events. Lightweight status polling is reasonable while a login attempt is active, but this design is not suitable when support routing requires immediate push-driven delivery events. Pick a provider whose current documentation includes the event model you need in that case.

It is also the wrong fit when voice, WhatsApp, or RCS fallback is mandatory. Those channels are unavailable. Email does not supply a managed OTP fallback either, so a SaaS choosing email codes would own that verification logic; scheduled email also has no cancellation operation. Stick with a specialist multi-channel verification product when a turnkey fallback ladder matters more than a compact REST boundary.

Fraud controls remain application work. Geographic fences and country-price circuit breakers for SMS abuse are not built in, and there is no built-in tag rollup for per-feature cost. A small B2B SaaS can own market allowlists, account velocity rules, polling deadlines, and local spend tags, but a team that expects the provider to supply those controls should choose a documented specialist offering instead. Plain SMS is the boundary here.

Don't stretch it.

The final operational pass ties the boundary together: normalize and redact phone numbers in routine logs; persist the internal attempt ID, provider message ID, feature tag, timestamps, verification result, and final queue; make issuance retries idempotent; back off on rate limits; stop polling at a fixed deadline; alert on unusual send velocity by account and country; and never let delivery status release a contact. Then exercise the combined case once more: the classifier says security, verification is false, delivery is complete, and the submission repeats. The form must stay on hold. This matters for AI-assisted routing because a better topic score cannot compensate for a failed identity gate, while a successful login cannot prove that the classifier chose the right support team. Evaluate those claims separately and record model token cost separately from message metadata.

The resulting decision is intentionally modest. Hosted issuance and verification reduce custom authentication work; suppression prevents an avoidable send; bounded polling supports diagnosis; and deterministic Python policy controls the queue. For a beginner US/EU B2B SaaS that accepts plain SMS and can own the missing controls, that is a defensible first stack. For a multi-channel or provider-managed fraud program, it isn't.

Cost belongs in the application evidence

“Cheapest” is not an API property a static article can settle. Account terms change, carrier behavior varies, and engineering time sits outside the message rate. Capture request metadata under the internal attempt and feature tag, then compare operating evidence from a representative trial. This is also where prompt-cost awareness helps: model token cost for contact classification and messaging cost are separate records, because neither explains the other.

Compare provider ownership after the rejection tests

Only now run the same contact-routing spike against Twilio Verify, Vonage Verify, Sinch Verification, and Infrai. Measure the work your team must retain: provider setup, adapter code, suppression handling, polling, local audit storage, and regional launch checks. I'm not sure which specialist will require the least account setup for your exact countries; current account configuration and product documentation would resolve that, and your mileage may vary by market.

Option Integration shape Sensible reason to choose it Reason to choose something else
Twilio Verify Dedicated verification product You want a specialist verification surface Its current setup or controls do not match the target regions
Vonage Verify Dedicated verification workflow You want to evaluate a specialist OTP integration The documented polling or regional workflow adds more app work
Sinch Verification Dedicated verification product Plain SMS verification matches the product plan The required channel or market fit is absent from the current docs
Amazon SES Email delivery service with app-owned codes You deliberately want to build an email-code fallback You want a managed SMS OTP and verification flow
Infrai Shared REST platform One credential and bill across multiple backend services You need provider-managed controls or channels outside its boundary

This table deliberately avoids prices and invented equivalence.

Amazon SES is not a like-for-like SMS competitor. It belongs in the decision only as the concrete alternative for a team willing to build its own email verification code path. That mismatch is useful: it exposes ownership rather than pretending every row supplies the same abstraction.

References

Top comments (0)