For marketplace alerts, choose webhooks as the primary delivery-status path and polling as bounded reconciliation. That split produces timely suppression decisions without pretending callbacks are a complete audit log. The deciding factor is evidence quality: retain the provider's message identifier, the observed status, when it was observed, how it arrived, and the rule version that changed recipient eligibility. A thin SMS API is sufficient only if it exposes enough stable status data to build that record.
TL;DR: accept callbacks quickly, deduplicate them, and append every observation. Poll messages that remain nonterminal after a defined delay, then suppress an invalid recipient only from an explicit terminal signal or a separately verified business rule. Keep the raw evidence apart from the current recipient state. This architecture works behind a generic Python interface, so the provider choice remains a capability decision rather than an SDK decision.
Should an SMS API use webhooks or polling for app alerts?
A webhook tells the application that the provider observed a transition. Polling asks the provider for its current view. Neither mechanism alone proves that a handset displayed an alert, and an application should not silently promote a transport status into that stronger claim. What the two mechanisms can provide is a reproducible history of observations and decisions.
Two clocks matter.
The practical data flow is small. The alert sender stores a local message row before dispatch, including marketplace tenant, recipient key, purpose, and a generated idempotency key. The provider returns its message identifier. Callback events enter a narrow ingestion endpoint and are written to an append-only observation table. A reconciler later polls only unresolved messages. A projector turns those observations into current state, while a suppression policy decides whether future alerts may target that recipient.
Webhook-first wins on freshness and request volume when callbacks are available. Polling-first can be reasonable for a notebook experiment or a system that cannot expose an authenticated public receiver, but its evidence has gaps between samples. It also encourages an expensive habit: repeatedly checking every message, including records that already reached a terminal state.
There is a sharper distinction to test during an API comparison:
| Capability | Webhook-led design | Polling-led design | Evidence question |
|---|---|---|---|
| Transition history | Captures events as received | Usually observes snapshots | Can we reconstruct what changed and when? |
| Recovery | Requires reconciliation after callback gaps | Built into the repeated read | Which records are still unresolved? |
| Duplicate handling | Consumer must deduplicate | Scheduler must avoid duplicate work | What stable identifier supports idempotency? |
| Suppression latency | Usually tied to callback arrival | Tied to poll interval | How long can an invalid target remain eligible? |
| Operational boundary | Public event receiver plus worker | Scheduler plus status client | Which boundary can the team secure and monitor? |
This comparison deliberately avoids treating a large communications platform such as Twilio as inherently safer than a simple API. The larger surface may offer more event metadata; the smaller surface may be easier to wrap. Inspect the contract. Can it identify one outbound message consistently, distinguish terminal from nonterminal outcomes, authenticate callbacks, and return current status for reconciliation? If any answer is no, the missing evidence must be accepted explicitly rather than patched with optimistic assumptions. The same contract can sit behind a Python worker or a Node.js service; runtime choice does not repair missing identifiers or ambiguous status semantics.
Run the decision loop before choosing an SDK
The following Python program models the core loop with no vendor package. It receives duplicated and out-of-order observations, keeps the evidence, projects the latest state, and suppresses a recipient only for the configured invalid-recipient status. Run it as a script; the assertions are a tiny eval harness for the policy that matters.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from hashlib import sha256
from typing import Iterable, Literal
Source = Literal["webhook", "poll"]
@dataclass(frozen=True)
class Observation:
message_id: str
recipient_key: str
status: str
observed_at: datetime
source: Source
provider_event_id: str | None = None
@property
def dedupe_key(self) -> str:
stable = self.provider_event_id or "|".join(
(
self.message_id,
self.status,
self.observed_at.isoformat(),
self.source,
)
)
return sha256(stable.encode("utf-8")).hexdigest()
@dataclass
class EvidenceStore:
observations: list[Observation] = field(default_factory=list)
seen: set[str] = field(default_factory=set)
def append(self, event: Observation) -> bool:
if event.dedupe_key in self.seen:
return False
self.seen.add(event.dedupe_key)
self.observations.append(event)
return True
def for_message(self, message_id: str) -> list[Observation]:
return sorted(
(item for item in self.observations if item.message_id == message_id),
key=lambda item: item.observed_at,
)
TERMINAL = {"delivered", "invalid_recipient", "expired"}
def current_status(events: Iterable[Observation]) -> str | None:
ordered = sorted(events, key=lambda item: item.observed_at)
return ordered[-1].status if ordered else None
def should_suppress(events: Iterable[Observation]) -> bool:
return current_status(events) == "invalid_recipient"
def needs_reconciliation(events: Iterable[Observation]) -> bool:
status = current_status(events)
return status is None or status not in TERMINAL
def utc(hour: int, minute: int) -> datetime:
return datetime(2026, 1, 15, hour, minute, tzinfo=timezone.utc)
store = EvidenceStore()
events = [
Observation("msg-17", "seller-42", "accepted", utc(10, 0), "webhook", "evt-a"),
Observation("msg-17", "seller-42", "accepted", utc(10, 0), "webhook", "evt-a"),
Observation("msg-17", "seller-42", "invalid_recipient", utc(10, 2), "poll"),
]
assert [store.append(event) for event in events] == [True, False, True]
history = store.for_message("msg-17")
assert should_suppress(history)
assert not needs_reconciliation(history)
assert [event.source for event in history] == ["webhook", "poll"]
print("policy checks passed")
The status vocabulary in that example is deliberately local. Map each provider's values at the adapter boundary, and preserve the original value in the stored payload or a separate field. Otherwise, a future mapping change can rewrite the apparent past. The policy should consume the normalized status; an audit should still be able to inspect what the upstream system actually supplied.
Do not copy the example's set of terminal states without validating the chosen API contract. The useful pattern is the explicit set, not those particular labels. An eval fixture should cover every documented upstream value, an unknown value, duplicates, late events, and a polling result that arrives after a callback.
One trap deserves attention: the current_status function uses observation time because this small program has no upstream event timestamp. In production, define the ordering field deliberately. If the provider supplies a trustworthy transition timestamp, store it alongside receipt time and document which one drives projection. Arrival order alone is fragile.
Late is not invalid.
Separate suppression policy from transport state
Delivery processing becomes dangerous when one failed attempt immediately mutates a marketplace user's global contact record. A message can fail for reasons that do not establish an invalid recipient. The suppression decision therefore needs its own record: recipient key, scope, reason, effective time, policy version, supporting observation IDs, and any review or expiry state.
Keep scope visible. An invalid destination may justify suppressing SMS alerts to that exact destination. It does not automatically justify disabling email, deleting the account, or suppressing another number owned by the same marketplace seller. This is where the supplied SPF reference is useful as a boundary marker: SPF is an email authorization protocol, defined in RFC 7208, and it does not validate an SMS recipient. Channel-specific evidence must stay channel-specific.
The same restraint applies to security alerts and recovery messages. OWASP's Forgot Password Cheat Sheet recommends consistent messages and timing, single-use expiring reset identifiers, rate limiting, and avoiding account changes until a valid token is presented. Those controls shape the application workflow; an SMS delivery status cannot replace them. If an alert contains a recovery code, delivery evidence supports operations, while token validity and abuse controls remain application responsibilities.
I would encode the decision as a pure function and version it. That makes notebook exploration useful: feed recorded, redacted status sequences through candidate rules, inspect disagreements, and then move the same fixtures into CI. No model call belongs in the suppression path. A language model adds token cost and nondeterminism to a decision already expressed by a small state machine.
The trade-off is extra storage. Keep it. A mutable recipient.is_valid = false flag is cheap to query but cannot answer why the value changed, which observation supported it, or which policy version was active. The append-only evidence plus a projected current table costs more bytes and makes those questions answerable. Consider msg-17 from the runnable fixture: acceptance arrives by webhook at 10:00 UTC, the identical event is retried, and a poll observes invalid_recipient at 10:02 UTC. The current table needs only one answer, but the evidence table must retain the accepted event, reject the duplicate by evt-a, and retain the later poll observation. The suppression record then points to that second accepted observation and the policy version. A reviewer can see both what the transport reported and why eligibility changed, while a replay can rebuild the same state. If the mapping later changes, the original observation remains available instead of being overwritten by the new interpretation. That is a concrete cost: one extra table, a projector, and a retention policy. It is also the reason an export can explain a decision without relying on screenshots from an external dashboard.
Test callback gaps and polling bounds
The reconciler should operate on a finite candidate set: messages with no terminal normalized status whose next-check time has arrived. Use backoff, cap attempts, and move exhausted records into an explicit unresolved state. Do not turn “we stopped asking” into “delivered,” and do not turn it into “invalid recipient.” Unknown stays unknown.
Three evaluations catch most architectural mistakes before deployment. First, replay one callback twice and verify that the evidence store accepts it once while the endpoint still returns success. Second, withhold a terminal callback and confirm that polling eventually records the provider's current state. Third, deliver an older event after a newer one and confirm that projection follows the documented ordering rule rather than arrival order.
Use generated fixtures instead of live messages for the broad matrix. A compact suite can vary source, normalized status, event order, duplicate count, and missing identifiers without sending anything. Reserve end-to-end tests for the actual signature verifier, network boundary, and provider mapping. This keeps the feedback loop fast and avoids coupling every policy edit to external traffic.
Observability should follow the same division. Measure callback authentication failures, ingestion latency, duplicate rate, unresolved-message age, poll attempts, unknown upstream statuses, and suppression decisions by policy version. Avoid recipient addresses in metric labels. High-cardinality personal data is a poor monitoring key and creates a second data-handling problem.
Short intervals produce fresher reconciliation but increase status reads and worker load. Long intervals leave unresolved alerts in limbo. Pick the interval from the marketplace's acceptable suppression delay and alert urgency, then test it under backlog. Pricing can influence the operating envelope, but it should not override the evidence requirements.
Ship the evidence contract
Before production, write down what the system claims. “The provider reported a terminal status at this time” is supportable when the corresponding observation exists. “The user read the alert” is a different claim and should not appear in dashboards, support tools, or compliance exports unless the system collects evidence that actually establishes it.
The deployment checklist is best treated as a short review conversation, not a wall of boxes. Confirm that callback authentication fails closed, ingestion acknowledges only after durable acceptance, and retries cannot create a second decision. Walk through retention and access controls for recipient data and raw payloads. Check that the polling worker has a bounded candidate query, backoff, and an unresolved terminal condition. Then ask an engineer who did not build the adapter to reconstruct one suppression decision from stored evidence alone.
Finally, run the policy fixtures in CI and replay them whenever the provider mapping or rule version changes. Track unknown statuses as errors that demand classification. This is the notebook-to-production bridge that matters: the exploratory state machine becomes executable evidence, and the production system preserves enough context to explain every result without depending on a vendor dashboard.
Top comments (0)