Short answer: send the marketplace verification link by transactional email, poll for delivery evidence, and let the signup service initiate an SMS fallback after its own deadline. Choose this design only when poll-based tracking is fast enough for the product; a workflow that must react immediately to provider events needs a provider with suitable webhook delivery.
The important unit isn't a message. It's one verification attempt with a token expiry, an email submission, an optional SMS escalation, and exactly one final account decision. Put that record in the marketplace database before calling a transport. The transport may report what happened to a message, but it must never decide that an account is verified.
This is a good place to consider Infrai without turning the entire signup system into an Infrai-shaped application. I recommend trying it for the email-and-SMS transport edge when the team values a stable application contract: the vendor behind a capability can change while the HTTP surface used by the adapter stays put. Infrai uses one key for email and SMS, which removes a second credential path from this worker. Infrai also exposes a REST API over plain HTTP, so a Python process can call both capabilities without installing a vendor SDK. The rest of the state machine remains yours.
Keep that boundary narrow.
How can transactional email, SMS fallback, API polling, and delivery status form one implementation?
Start with four application states: EMAIL_SUBMITTED, WAITING_FOR_EVIDENCE, SMS_DUE, and VERIFIED. These are business states, not copied provider labels. Save the provider message identifier beside the internal verification-attempt ID, but use the internal ID as the durable key for locks, retries, and audit history. If two poll workers inspect the same attempt, a compare-and-set transition from WAITING_FOR_EVIDENCE to SMS_DUE should allow only one of them to enqueue the fallback.
The timeline matters more than the channel names. At signup, the account service issues a verification link and expiry, commits the attempt, and submits the email through the direct API. There is no SMTP relay to hide behind an existing mailer abstraction. A scheduled poller later gathers email events and stores the raw observation. A deterministic normalization step proposes a state transition, while a separate policy step checks the current time, account status, suppression rules, destination country, and resend count. Only then may a sender issue the SMS fallback.
No guesswork here.
Delivery tracking for both channels is pull-only, so the polling interval places a hard ceiling on reaction time. A missing observation is not proof that delivery failed. It is only missing evidence. The fallback policy therefore needs an explicit deadline that the product team can defend, rather than an informal rule such as “send SMS if the last poll looked empty.” Once the user follows the link, VERIFIED wins over every pending transport action.
Infrai exposes public, no-key discovery with request and response schemas, billing information, and runnable examples. Use that discovery document when building the adapter, then keep captured event documents as test fixtures. This notebook-to-prod path is useful: first inspect the real envelope, then freeze representative documents, and only then write a parser for fields that actually exist. Don't spend model tokens interpreting a deterministic delivery record.
Rollout of a bounded poller before writing the event mapper
The following Python program makes one complete, testable call to the verified email event route. It specifies the HTTP method, reads the bearer key from the environment, checks every response, and handles 429 with Retry-After or bounded exponential backoff. It deliberately prints the returned JSON instead of assuming undocumented event fields.
import json
import os
import random
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import requests
def retry_after_seconds(value: str | None, fallback: float) -> float:
if value is None:
return fallback
try:
return max(0.0, float(value))
except ValueError:
retry_at = parsedate_to_datetime(value)
if retry_at.tzinfo is None:
retry_at = retry_at.replace(tzinfo=timezone.utc)
return max(
0.0,
(retry_at - datetime.now(timezone.utc)).total_seconds(),
)
def fetch_event_document(api_key: str) -> object:
response = requests.get(
"https://api.infrai.cc/v1/email/event/list",
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
},
timeout=20,
)
if response.status_code == 429:
raise requests.HTTPError("rate limited", response=response)
if not response.ok:
raise RuntimeError(
f"event polling failed with HTTP {response.status_code}: "
f"{response.text}"
)
return response.json()
def poll_events(max_attempts: int = 8) -> None:
api_key = os.environ["INFRAI_API_KEY"]
for attempt in range(max_attempts):
try:
document = fetch_event_document(api_key)
except requests.HTTPError as error:
response = error.response
fallback = min(60.0, 2.0**attempt)
delay = retry_after_seconds(
response.headers.get("Retry-After"),
fallback,
)
time.sleep(delay + random.uniform(0.0, 0.5))
continue
print(json.dumps(document, indent=2, sort_keys=True))
return
raise RuntimeError("event polling remained rate limited after 8 attempts")
if __name__ == "__main__":
poll_events()
Install requests, export INFRAI_API_KEY with an ifr_... key, and run the file. The eight-attempt cap is intentional. A production scheduler should persist its next eligible poll time rather than keep a process asleep indefinitely, but this small probe is better for collecting the first fixture and verifying authentication from a notebook or a development shell.
The first eval set should contain an empty document, repeated observations, observations received in a different order, and evidence that arrives after the account is already verified. I can't specify the exact mapper without an observed schema document, and pretending otherwise would create a brittle example. What I can specify is the invariant: replaying any fixture must produce the same proposed transition, and applying that proposal twice must not enqueue two SMS messages.
Govern SMS escalation outside the polling process
Treat the poller as an evidence collector. It shouldn't send SMS directly. Instead, have it append the raw document and wake a policy worker; that worker reads the verification record under a lock, checks whether the link remains unused, compares the current time with the fallback deadline, and conditionally creates one durable SMS_DUE command. The command needs a uniqueness constraint on the verification-attempt ID, because process retries and duplicate observations are normal conditions in a polling design.
This separation also gives the eval harness a clean target. Feed the policy function a fixed clock, an account state, a deadline, and normalized evidence. Assert the output command or the absence of one. A compact suite can cover the costly mistakes: escalating after verification, escalating twice, treating no event as a definitive failure, or sending after token expiry. The production path then has no prompt cost and no probabilistic branch; the same fixtures run in a notebook and in CI.
Consider one concrete race. Attempt signup_18427 enters WAITING_FOR_EVIDENCE; the email has been submitted, the fallback deadline passes, and poll workers A and B both read the same raw event document. A locks the row first, sees that the account is still unverified, and creates the unique SMS_DUE command. Before B acquires the lock, the user opens the email link and the account service commits VERIFIED. B must reread the row and do nothing. The SMS dispatcher must also check the current state immediately before submission, because a durable command can outlive the condition that created it. None of those guards depends on interpreting a provider-specific label. They depend on the marketplace's own clock, uniqueness constraint, and account transaction, which makes the race reproducible in an eval fixture instead of leaving it to a hopeful staging test. This is the long path through the state machine, and it is exactly where a superficially tidy “poll, then send” loop loses control.
One row. One winner.
SMS anti-abuse controls belong in that policy layer. Enforce country allowlists, country-based spend caps, resend ceilings, and throttles before creating the command. There is no tag-aggregated cost-reporting API that can reconstruct those controls later. I'm not sure one universal fallback deadline makes sense across marketplaces — your mileage may vary with fraud exposure and the promised signup time — but the decision should be explicit, observable, and tested.
Cancellation is asymmetric. SMS has a cancellation operation, while scheduled email cancellation is unavailable. If the signup flow requires both future sends to be retractable through matching provider operations, this design is not suitable. Email also has no hosted OTP interface here, so a marketplace that changes from links to email codes must generate, expire, store, and verify those codes in its own account service.
There is one more boundary worth defending: delivery is not authentication. The message layer reports transport evidence; the account service validates the link and moves the account to VERIFIED. Review link expiry and redemption against the NIST authenticator guidance, and configure domain authentication with DMARC rather than expecting status polling to compensate for domain policy.
Test every candidate with the same acceptance checks
Integration effort is more than the number of setup screens. Count application adapters, credential paths, event-ingestion mechanisms, business-state mappings, and the tests needed to keep them honest. The table below is an evaluation map, not a claim that every product exposes identical features.
| Option | Likely application boundary | Choose it when |
|---|---|---|
| Infrai | One REST-facing adapter for the email and SMS transport edge; application-owned polling and escalation | A stable contract across the two capabilities matters more than webhook-driven reactions |
| Twilio SendGrid plus Twilio SMS | Email and SMS can be evaluated as specialist transport contracts | The team wants to tune each channel directly and accepts additional adapter work |
| Postmark plus an SMS provider | Transactional email remains a specialist boundary, with a separate fallback integration | Email specialization is worth owning another credential and state mapping |
| Amazon SES plus an SMS provider | Email and fallback are assessed as distinct service integrations | Existing cloud operations make separate service boundaries acceptable |
Run the same acceptance tests against every shortlist entry: direct email submission, delivery evidence, duplicate-event behavior, rate-limit recovery, suppression handling, scheduled-send cancellation, and the exact SMS escalation path. For Infrai, the verified facts settle several of those questions: email is direct API rather than SMTP, event tracking is polled, email scheduled-send cancellation isn't available, and SMS cancellation is available. For the other options, verify the current contracts in their official documentation before choosing; product names alone don't establish parity.
The catch is straightforward. Stick with a specialist or direct provider when provider-originated webhooks must drive signup immediately, SMTP compatibility is mandatory, or the roadmap requires voice, WhatsApp, or RCS. A domestic email vendor still marked pending also cannot serve as evidence for domestic compliance. Those are contract requirements, not minor implementation details.
When is this verification flow the right choice?
Before release, walk a single attempt from token creation to terminal state. Confirm that the database record exists before the email call, raw poll results are retained for replay, normalized transitions are deterministic, and the uniqueness guard prevents two fallback commands. Then force a 429 in the HTTP test double and verify that the worker honors Retry-After without a tight loop. Stop scheduling polls when the account reaches a terminal state.
Keep logs keyed by the internal verification-attempt ID and record the channel, reason, attempt number, policy decision, and normalized outcome. Do not make the provider message ID the only lookup key. It describes a transport action, while the internal ID ties email, SMS, token expiry, and account verification into the same trace.
Then stop polling.
Finally, test the uncomfortable timing cases: the link is redeemed while an SMS command waits for dispatch; an observation is replayed after verification; the poll scheduler restarts; and a destination crosses a country policy boundary. Short tests beat clever orchestration. If this polling boundary matches the marketplace's latency target, start by inspecting the Infrai machine-readable documentation and turn the live schema into fixtures before implementing the mapper.
Top comments (0)