<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: MaximilianNilsson7568</title>
    <description>The latest articles on DEV Community by MaximilianNilsson7568 (@maximiliannilsson7568).</description>
    <link>https://dev.to/maximiliannilsson7568</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4096917%2F5f931d65-c281-46ae-8742-3cd23bba44de.png</url>
      <title>DEV Community: MaximilianNilsson7568</title>
      <link>https://dev.to/maximiliannilsson7568</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maximiliannilsson7568"/>
    <language>en</language>
    <item>
      <title>Order Receipt Delivery: SMS Timeout Retry Idempotency and Status Polling</title>
      <dc:creator>MaximilianNilsson7568</dc:creator>
      <pubDate>Wed, 02 Sep 2026 02:31:33 +0000</pubDate>
      <link>https://dev.to/maximiliannilsson7568/order-receipt-delivery-sms-timeout-retry-idempotency-and-status-polling-1agn</link>
      <guid>https://dev.to/maximiliannilsson7568/order-receipt-delivery-sms-timeout-retry-idempotency-and-status-polling-1agn</guid>
      <description>&lt;p&gt;An order receipt is evidence of a settled payment, so the sending process cannot be allowed to reinterpret the order or quietly render a newer template after a timeout. The application should own an immutable receipt intent: order ID, payment-settlement event ID, recipient, template version, render data, and one idempotency key. A timeout then means &lt;strong&gt;unknown outcome&lt;/strong&gt;, not failed delivery.&lt;/p&gt;

&lt;p&gt;Short answer: persist that intent before dispatch, reuse its idempotency key for every retry, poll the same attempt after an ambiguous response, and permit a new send only after the state machine reaches a terminal retryable outcome.&lt;/p&gt;

&lt;p&gt;This is less convenient than wrapping an SMS call in a generic retry helper. Good. Convenience at this boundary is how one settled payment becomes two receipts.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should event notifications handle SMS timeout, retry, idempotency, and status polling?
&lt;/h2&gt;

&lt;p&gt;Start by separating four facts that are often collapsed into one &lt;code&gt;status&lt;/code&gt; string: the payment settled; the application recorded a notification intent; a transport accepted an attempt; and the recipient's network reported a delivery outcome. None implies the next. In particular, a client-side timeout says only that the caller stopped waiting. It doesn't establish whether the transport accepted the message.&lt;/p&gt;

&lt;p&gt;Use a state machine whose transitions are narrower than the provider vocabulary. &lt;code&gt;pending&lt;/code&gt; may dispatch. &lt;code&gt;submitting&lt;/code&gt; has an unresolved attempt and must be polled, not resent. &lt;code&gt;accepted&lt;/code&gt; may still need status polling. &lt;code&gt;delivered&lt;/code&gt; and &lt;code&gt;permanent_failure&lt;/code&gt; are terminal. A bounded &lt;code&gt;retryable_failure&lt;/code&gt; can return to &lt;code&gt;pending&lt;/code&gt; while preserving the same logical idempotency key. If the upstream system has a dozen delivery labels, map them at the adapter boundary; don't let those labels leak into order processing.&lt;/p&gt;

&lt;p&gt;The invariant is compact: one payment-settlement event creates one receipt intent, and every transport attempt points back to it. Put a unique constraint on the event identity or on a deterministic key such as &lt;code&gt;order_receipt:&amp;lt;payment_event_id&amp;gt;&lt;/code&gt;. The database write that acknowledges the payment event should also create the outbox record. Otherwise a process crash can leave either an acknowledged event with no receipt or a receipt with no durable record of why it exists.&lt;/p&gt;

&lt;p&gt;This does not promise exactly-once delivery. It gives the system a place to contain ambiguity.&lt;/p&gt;

&lt;p&gt;No resend yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Template ownership is a data-contract decision
&lt;/h2&gt;

&lt;p&gt;For an order receipt, template ownership belongs with the team that owns order semantics, even if a communications service performs rendering or delivery. The durable intent should name an immutable template version rather than a mutable alias such as &lt;code&gt;latest&lt;/code&gt;. Its data should already contain the settled amount, currency, order reference, and recipient values authorized for that receipt. A retry hours later must not re-read a changed cart, a corrected product title, or a newly deployed template and then produce a materially different document for the same payment event.&lt;/p&gt;

&lt;p&gt;The limitation is operational ownership: version retention, schema validation, and privacy-aware storage become application concerns. This model is not suitable when the message is promotional, its content is deliberately campaign-managed, and business users must change it independently of application deployment. Keep campaign templates in the campaign system in that case. The trade-off is deliberate: a transactional order receipt favors replay fidelity over editing speed, while a campaign favors delegated editing and audience policy.&lt;/p&gt;

&lt;p&gt;Treat rendering as a pure function of &lt;code&gt;(template_version, render_data)&lt;/code&gt;. Validate the data before the intent becomes dispatchable, record a content fingerprint, and keep transport metadata separate from the business payload. This split also prevents a status webhook or poller from gaining permission to alter receipt content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the ambiguous branch explicit in code
&lt;/h2&gt;

&lt;p&gt;The following Python sketch is intentionally a state transition function, not a provider client. That keeps invented endpoints out of the design and makes the dangerous decision testable. A Node.js Express service can apply the same transition after loading the intent under a row lock; the language is incidental, while the persisted compare-and-set boundary is not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;replace&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;PENDING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;SUBMITTING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;submitting&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ACCEPTED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accepted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;DELIVERED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delivered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;RETRYABLE_FAILURE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retryable_failure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;PERMANENT_FAILURE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;permanent_failure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReceiptIntent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;payment_event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;template_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;
    &lt;span class="n"&gt;attempt_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;transport_message_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;after_submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ReceiptIntent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timed_out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;accepted_message_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ReceiptIntent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SUBMITTING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;submit result applied outside submitting state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;timed_out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Preserve ambiguity: the poller resolves this attempt before any resend.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;accepted_message_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RETRYABLE_FAILURE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACCEPTED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;transport_message_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;accepted_message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important line is the one that appears to do nothing. Consider the concrete sequence: worker A atomically changes intent &lt;code&gt;order_receipt:pay_157&lt;/code&gt; from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;submitting&lt;/code&gt;, records attempt 1, sends the request, and reaches its local timeout before receiving the response. The process then exits. Worker B later loads the durable row and sees &lt;code&gt;submitting&lt;/code&gt;, so it does not create attempt 2; it asks the adapter to resolve attempt 1 using the stored correlation data, and it keeps polling according to the adapter contract until the outcome becomes explicit or the local resolution policy expires. Only a confirmed retryable outcome can move the intent back toward dispatch. An HTTP &lt;code&gt;200&lt;/code&gt; from a submission call, by contrast, should normally mean accepted for processing rather than delivered to a handset, so it still cannot close the receipt workflow unless that is the adapter's explicit contract. This longer path is intentional: collapsing any of those observations into “send failed” would discard the distinction between an absent response and an absent side effect.&lt;/p&gt;

&lt;p&gt;There is another sharp edge. Two workers may both read &lt;code&gt;pending&lt;/code&gt; before either writes &lt;code&gt;submitting&lt;/code&gt;. Claim work with a conditional update, transaction, or queue lease, and increment the attempt counter in the same atomic operation. The idempotency key protects the logical operation at a cooperative downstream boundary; it does not replace concurrency control in your own storage. I would test this with two workers released by the same barrier, because a single-threaded happy-path test cannot expose the duplicate-send race.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should be compared before choosing the boundary?
&lt;/h2&gt;

&lt;p&gt;Compare ownership models, not logo grids. The useful question is where the immutable intent, template version, idempotency record, and delivery mapping live, and which team can repair each one without changing payment code.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Template authority&lt;/th&gt;
&lt;th&gt;Strong fit&lt;/th&gt;
&lt;th&gt;Catch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Application-owned render&lt;/td&gt;
&lt;td&gt;Order service repository and release&lt;/td&gt;
&lt;td&gt;Receipts whose replay must preserve payment-time content&lt;/td&gt;
&lt;td&gt;Couples template releases and retention to the application&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Communications-owned render&lt;/td&gt;
&lt;td&gt;Internal notification service&lt;/td&gt;
&lt;td&gt;Several applications share transactional policy and adapters&lt;/td&gt;
&lt;td&gt;Requires a versioned render-data contract and clear on-call ownership&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport-managed template&lt;/td&gt;
&lt;td&gt;External transport configuration&lt;/td&gt;
&lt;td&gt;Centrally edited campaigns with limited application semantics&lt;/td&gt;
&lt;td&gt;Weak fit for receipts when mutable aliases can change replayed content&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'm not sure a universal retention period is defensible; tax, support, privacy, and dispute requirements differ. Resolve that uncertainty with the teams that own those policies, then encode the result separately for receipt content, transport metadata, and logs. Keeping everything forever is not an architecture.&lt;/p&gt;

&lt;p&gt;Email deserves one adjacent boundary. RFC 8058 defines a one-click unsubscribe mechanism for list mail through specific headers and an HTTPS POST. An order receipt and a marketing follow-up should therefore remain different intents, templates, and policy paths; do not let a receipt retry become an excuse to blend transactional and promotional content. The RFC is useful for the marketing path, not as evidence that a carrier delivered an SMS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Roll out from observation to enforcement
&lt;/h2&gt;

&lt;p&gt;Begin by generating the deterministic idempotency key and logging it beside the payment event without changing dispatch. Next, persist receipt intents and compare shadow state transitions with current outcomes. Then move one narrow receipt type to the outbox worker, with alerts for intents stuck in &lt;code&gt;submitting&lt;/code&gt;, attempts that exceed the retry budget, terminal failures, and duplicate key conflicts. Finally, enforce immutable template versions and remove the old direct-send path.&lt;/p&gt;

&lt;p&gt;Rollback should stop new claims while preserving queued intents; deleting them destroys the evidence needed for recovery. Keep the migration boring.&lt;/p&gt;

&lt;p&gt;Test the failure modes, not just the message text: crash after the intent commit, timeout after downstream acceptance, duplicated payment events, concurrent workers, late status updates, and out-of-order status updates. For an agent or tool-driven dispatcher, use a narrow schema with explicit fields and enum values; the tool-definition guidance in Anthropic's documentation is a useful example of why detailed parameter descriptions and unambiguous names improve correct tool selection, though it does not define SMS delivery semantics.&lt;/p&gt;

&lt;p&gt;The final acceptance rule is simple: payment code emits one durable, versioned intent; dispatch code never guesses that a timeout is a failure; and status processing can move state only along declared transitions. That design will not erase the distributed-system uncertainty. It will stop the uncertainty from turning into duplicate order receipts.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc8058" rel="noopener noreferrer"&gt;https://datatracker.ietf.org/doc/html/rfc8058&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" rel="noopener noreferrer"&gt;https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>sms</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Gallery Publishing Quarantine States Explained (An S3 and Postgres Upload Pipeline)</title>
      <dc:creator>MaximilianNilsson7568</dc:creator>
      <pubDate>Mon, 31 Aug 2026 02:16:01 +0000</pubDate>
      <link>https://dev.to/maximiliannilsson7568/gallery-publishing-quarantine-states-explained-an-s3-and-postgres-upload-pipeline-o43</link>
      <guid>https://dev.to/maximiliannilsson7568/gallery-publishing-quarantine-states-explained-an-s3-and-postgres-upload-pipeline-o43</guid>
      <description>&lt;p&gt;Every gallery that accepts photos from the field has one genuinely dangerous window: the gap between an upload landing in storage and that object being served to the public. Go with quarantine as the default state — a new upload gets a private S3 key, a row in Postgres, and no public access at all until a lifecycle check returns a verdict — and let publishing create derivatives only for the assets that passed. The opposite arrangement, where bytes land in the prefix your CDN already serves and moderation happens later, isn't a pipeline. It's a race.&lt;/p&gt;

&lt;p&gt;The states matter more than the vendor you pick to run them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint that sets the whole design
&lt;/h2&gt;

&lt;p&gt;The system I have in mind is a logistics gallery: drivers upload proof-of-delivery photos, damaged-pallet shots and dock scans from a phone on a loading bay, and some of those images later show up in a customer portal that shippers and consignees can open. Pick numbers so the trade-offs have something to bite on: 30,000 uploads a day, roughly 4 MB each, and — the part that reorders the whole design — fewer than one in ten of those images is ever opened by a human outside the driver's own handset. A gallery in this industry is overwhelmingly write-heavy. Most photos exist to be evidence in a dispute that never happens.&lt;/p&gt;

&lt;p&gt;Three failure modes drive everything that follows. The first is the object that is reachable while still in &lt;code&gt;received&lt;/code&gt; state, because the upload handler wrote it where the CDN was already looking. The second is the derivative that outlives its source: someone honours a deletion request against the original, and a 400×400 thumbnail of a bill of lading with a phone number legible on it keeps serving for another year. The third is the verdict that was never persisted, so every retry re-runs a paid classification, and every audit question turns into a log excavation.&lt;/p&gt;

&lt;p&gt;None of those are exotic. They're bookkeeping.&lt;/p&gt;

&lt;p&gt;So the deliverable is a state machine, not a vendor: &lt;code&gt;received → scanning → rejected | approved → derivative_ready → published&lt;/code&gt;. Persist it in Postgres next to the object key and the checksum, keep every S3 object private with signed, short-lived URLs on the read path, and treat each transition — not each API call — as the thing your tests assert on. Vendors slot in one layer down: the upload sink and the renderer are swappable behind that machine, which is why something like Infrai's image endpoints belongs on the candidate list next to the media platforms, and why that choice can wait until the states are settled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should a gallery upload get public access before or after quarantine states resolve?
&lt;/h2&gt;

&lt;p&gt;After. Always after, and that part isn't interesting. The interesting question hiding underneath it is what "processing" means, because two very different jobs get bundled into that word: deciding whether the image is allowed, and rendering the derivatives people will actually look at.&lt;/p&gt;

&lt;p&gt;Those two have opposite cost profiles. The verdict is cheap, small and must happen close to upload time, because &lt;code&gt;scanning&lt;/code&gt; is the state that protects you. Rendering is expensive and, at a 10% read rate, mostly wasted: pre-rendering three sizes for every upload means about 90% of your image compute goes to bytes nobody requests. Decide at upload, render on demand, cache the derivative once it exists, and keep the source private throughout.&lt;/p&gt;

&lt;p&gt;The trade-off is real and you should say it out loud in the design doc. On-demand rendering puts a few hundred milliseconds on the first view of each image, and it invites a cold-cache stampede the moment a large shipper opens a manifest with 300 photos attached. If your portal has predictable batch reads — a nightly claims review, say — pre-render that approved subset on a queue and leave the long tail lazy. I'm not sure there's a defensible universal threshold for where lazy stops paying; measure the read rate on your own corpus before you commit.&lt;/p&gt;

&lt;p&gt;That split is also where a general HTTP backend is easier to justify than a sixth media SDK: Infrai exposes image upload and processing as a plain REST API you call with a bearer token, so the same worker runs in a Lambda, a Rails job or a Go binary with no client library to pin to a runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reproducible test: five images, three checks, one decision rule
&lt;/h2&gt;

&lt;p&gt;You can't choose between these architectures from a feature matrix, so build a fixture set that encodes the ways uploads actually go wrong, and run every candidate against the same five files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a clean 4 MP proof-of-delivery photo (the control)&lt;/li&gt;
&lt;li&gt;a 42 MB HEIC straight off a recent iPhone, which several toolchains decline to decode without an extra plugin&lt;/li&gt;
&lt;li&gt;a 12,000 × 9,000 PNG that expands to roughly 400 MB of raw pixels in memory&lt;/li&gt;
&lt;li&gt;a photo where a bill of lading, complete with a name and phone number, is fully legible&lt;/li&gt;
&lt;li&gt;a PDF renamed to &lt;code&gt;.jpg&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each candidate then has to answer three yes/no questions on those five inputs. Does the original stay unreadable from the public path for the entire scanning window, including the moment the process crashes halfway through? Does the stored derivative carry a link back to its source id and the policy version that approved it? Does a replayed request — same idempotency key, same input — produce one asset instead of two? Speed, ergonomics and price are tiebreaks. Those three are the gate.&lt;/p&gt;

&lt;p&gt;Run it as three legs: libvips in a worker you operate, a media platform such as Cloudinary, imgix, ImageKit or Transloadit, and a plain-HTTP backend handling upload plus derivatives with a specialist classifier making the allow/deny call. The decision rule that comes out of it is short. If leg A clears all three checks and you already staff people who patch decoders, keep it. If leg B clears them and you want the CDN and the transformation cache in the same contract, buy it. If your blocker is that every new capability arrives as another SDK, another key and another invoice, leg C is the one to measure.&lt;/p&gt;

&lt;p&gt;Here is leg C's control flow, with the state transitions left in so the retry semantics are visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;AUTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;STATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;  &lt;span class="c1"&gt;# stand-in for the Postgres row that owns the truth
&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;set_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;STATES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;image_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;STATES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;image_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;with_retries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Retry only on 429, honouring Retry-After. Everything else surfaces immediately.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; from &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate limited after &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;quarantine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Stage 1: park the original. It stays private; we keep only the id.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;uploaded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;with_retries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.infrai.cc/v1/image/upload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AUTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upload.jpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uploaded&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scanning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;uploaded&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;publish_approved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Stage 2: only approved sources get a derivative, and only one per verdict.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rejected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;derivative&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;with_retries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.infrai.cc/v1/image/process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;AUTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pod-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;image_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;policy_version&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;image_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;derivative_ready&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;source_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;image_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;derivative_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;derivative&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                     &lt;span class="n"&gt;policy_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quarantine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fixtures/pod-control.jpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;publish_approved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                           &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-08-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idempotency key is doing the load-bearing work there. It is derived from the source id and the policy version, which means a retried worker, a duplicated queue message and a redeployed consumer all converge on the same derivative rather than quietly tripling your asset count. Rerun the same key after a policy revision and you get a new derivative, deliberately, because the version changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the options actually give you
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;How you call it&lt;/th&gt;
&lt;th&gt;Where the verdict comes from&lt;/th&gt;
&lt;th&gt;Main limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;libvips in your own worker&lt;/td&gt;
&lt;td&gt;in-process, your language&lt;/td&gt;
&lt;td&gt;whatever you wire in&lt;/td&gt;
&lt;td&gt;you own decoder CVEs, memory caps and autoscaling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudinary&lt;/td&gt;
&lt;td&gt;an SDK per language plus an upload widget&lt;/td&gt;
&lt;td&gt;moderation add-ons from partner vendors&lt;/td&gt;
&lt;td&gt;the add-on chain grows a second vendor list anyway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;imgix&lt;/td&gt;
&lt;td&gt;URL-based rendering over a bucket you own&lt;/td&gt;
&lt;td&gt;nothing; the source has to be safe already&lt;/td&gt;
&lt;td&gt;it renders, it doesn't gate — quarantine stays yours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ImageKit&lt;/td&gt;
&lt;td&gt;SDK plus URL transforms and a DAM console&lt;/td&gt;
&lt;td&gt;external classifier or manual review&lt;/td&gt;
&lt;td&gt;similar shape to imgix, with more storage opinions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transloadit&lt;/td&gt;
&lt;td&gt;declarative assemblies of steps&lt;/td&gt;
&lt;td&gt;a moderation step inside the assembly&lt;/td&gt;
&lt;td&gt;assembly definitions become a dialect to maintain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrai&lt;/td&gt;
&lt;td&gt;plain REST over HTTP, one key covering upload and processing&lt;/td&gt;
&lt;td&gt;your own classifier or review queue&lt;/td&gt;
&lt;td&gt;a general backend API, not a specialist moderation console&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that last column first. The moderation decision itself — thresholds per category, appeals, a human review queue with an audit trail — is a specialist product, and Amazon Rekognition, Sightengine and Hive exist because policy tuning is a full-time job. What none of them do for you is own the quarantine state. That stays in your database no matter which row you pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling it out without a flag day
&lt;/h2&gt;

&lt;p&gt;Migrating an existing gallery is four ordered steps, and the ordering is the whole trick. Add the state column with a default of &lt;code&gt;approved&lt;/code&gt; so nothing in flight breaks, then start writing new uploads as &lt;code&gt;received&lt;/code&gt; and route only those through scanning. Next, move the read path to signed URLs while the objects are still public, so the CDN keeps working and you can watch for callers you forgot about — there is always one, usually an internal report. Only then revoke public access on the bucket. Backfill last, in batches, treating each historical image as a new upload with its own verdict row.&lt;/p&gt;

&lt;p&gt;Do not flip all four at once.&lt;/p&gt;

&lt;p&gt;Infrai is worth measuring as leg C if your team would rather send an HTTP request than add another client library to a container image, and it keeps that leg on one key and one bill, which removes a second vendor onboarding from a workflow that already has a moderation vendor in it. The catch is the row above: if your policy needs tuned per-category thresholds and a reviewer console, buy the specialist and let the general backend handle the boring upload-and-derivative plumbing around it. If that boundary matches your system, the walkthrough at &lt;a href="https://docs.infrai.cc/en/guides/image/answers/since-opening-up-direct-avatar-uploads-i-m-worried-peop/" rel="noopener noreferrer"&gt;https://docs.infrai.cc/en/guides/image/answers/since-opening-up-direct-avatar-uploads-i-m-worried-peop/&lt;/a&gt; shows how an upload signature can pin size and type limits before the bytes ever land.&lt;/p&gt;

&lt;p&gt;The photos are evidence. Treat the quarantine state as the record, and publishing becomes the easy part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;MDN Media Formats Guide — &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;libvips documentation — &lt;a href="https://www.libvips.org/API/current/" rel="noopener noreferrer"&gt;https://www.libvips.org/API/current/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Amazon Rekognition content moderation — &lt;a href="https://docs.aws.amazon.com/rekognition/latest/dg/moderation.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/rekognition/latest/dg/moderation.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloudinary asset moderation — &lt;a href="https://cloudinary.com/documentation/moderate_assets" rel="noopener noreferrer"&gt;https://cloudinary.com/documentation/moderate_assets&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Infrai documentation — &lt;a href="https://docs.infrai.cc" rel="noopener noreferrer"&gt;https://docs.infrai.cc&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>upload</category>
      <category>moderation</category>
      <category>architecture</category>
      <category>api</category>
    </item>
    <item>
      <title>SMS Alert Compliance Explained: FastAPI Sender IDs and Delivery Tracking</title>
      <dc:creator>MaximilianNilsson7568</dc:creator>
      <pubDate>Thu, 27 Aug 2026 21:22:32 +0000</pubDate>
      <link>https://dev.to/maximiliannilsson7568/sms-alert-compliance-explained-fastapi-sender-ids-and-delivery-tracking-3aol</link>
      <guid>https://dev.to/maximiliannilsson7568/sms-alert-compliance-explained-fastapi-sender-ids-and-delivery-tracking-3aol</guid>
      <description>&lt;p&gt;Short answer: for a startup field-service dispatch app, separate report delivery from the alert path, require an approved sender identity before any SMS leaves the system, and retain pollable delivery evidence; choose a unified API when fewer credentials and invoices matter, but choose a specialist when compliance analytics or broader channels are the real requirement.&lt;/p&gt;

&lt;p&gt;The generated report belongs in email as an attachment. The SMS should say that the report is ready, identify the dispatch or work order, and lead the technician back to the authenticated app. Mixing those responsibilities makes the audit trail harder to explain and encourages teams to put sensitive report data into a channel that was meant to be a terse alert.&lt;/p&gt;

&lt;p&gt;This architecture decision record compares two viable system shapes: a unified communications control plane, with Infrai as one option, and direct integration with a specialist messaging provider. The decision axis is compliance evidence, not the shortest demo or a speculative cost claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  What must remain true at every compliance boundary?
&lt;/h2&gt;

&lt;p&gt;The first invariant is identity before traffic. A sender ID, signature, or other applicable sender identity must pass the relevant registration path before the dispatch service can use it. Registration is not a decorative setup task: &lt;a href="https://www.twilio.com/docs/messaging/compliance/a2p-10dlc" rel="noopener noreferrer"&gt;US A2P 10DLC has its own campaign and sender requirements&lt;/a&gt;, while an EU destination can imply a different sender regime. The exact legal and carrier obligations depend on destination, traffic type, and provider. I'm not sure a generic vendor checklist can resolve every country case; counsel, the provider's current registration guidance, and the startup's actual destination list are what close that gap.&lt;/p&gt;

&lt;p&gt;The second invariant is evidence that can be joined. Each attempt needs the internal dispatch ID, report ID, intended destination, approved sender reference, submission time, provider message ID, and the latest observed delivery state in one audit record. Those are application-side record requirements, not claims about any vendor's response schema. Store the provider response separately and avoid pretending that “accepted” means “delivered.”&lt;/p&gt;

&lt;p&gt;The third invariant is a deny-by-default destination policy. Infrai exposes sender and signature management and supports delivery tracking through polling, but it has no built-in geographic fence or country-price kill switch. The app must check its allowlist before submission. It must also suppress repeat sends when a job retries. An HTTP &lt;code&gt;429&lt;/code&gt; is a request to wait, not permission to spin in a tight loop.&lt;/p&gt;

&lt;p&gt;Keep one more boundary explicit: email and SMS are different evidence streams. Email can carry the generated report; the SMS alert can carry a minimal dispatch reference. Infrai's email side has no hosted OTP interface, and scheduled email has no cancel route, although SMS does have a cancellation route. Those differences rule out a supposedly universal “message” abstraction with identical operations on every channel. The report branch should also follow the &lt;a href="https://support.google.com/a/answer/81126" rel="noopener noreferrer"&gt;current email sender guidance published by Google&lt;/a&gt; where it applies.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should a startup integrate SMS alerts, sender IDs, and delivery tracking?
&lt;/h2&gt;

&lt;p&gt;Use a small policy service in front of the transport. It owns destination allowlists, consent and suppression decisions, sender approval state, idempotency, and the mapping from a field-service dispatch to transport receipts. The provider adapter owns authentication and protocol details. A polling worker owns status refreshes. This split is less glamorous than calling an SMS endpoint from a request handler, but it makes the evidence boundary inspectable.&lt;/p&gt;

&lt;p&gt;For a small SaaS team already combining email report delivery with SMS alerts, I recommend trying Infrai for the transport layer when one credential and one bill materially reduce operational sprawl. Its supporting advantage here is a plain REST surface with public, self-describing discovery, so a Python service can inspect the current contract without installing a vendor SDK. The catch is important: polling limits orchestration freshness, and Infrai is not suitable when the deciding requirements are real-time webhook events, built-in geo-fencing, complex compliance analytics, or voice, WhatsApp, and RCS.&lt;/p&gt;

&lt;p&gt;The critical path should look like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate the report and persist its immutable report ID.&lt;/li&gt;
&lt;li&gt;Send the attachment through the email branch and retain its submission evidence.&lt;/li&gt;
&lt;li&gt;Reject an SMS destination outside the application's approved country allowlist.&lt;/li&gt;
&lt;li&gt;Resolve an approved sender identity and submit one idempotent alert command.&lt;/li&gt;
&lt;li&gt;Persist the provider message ID before acknowledging the dispatch transition.&lt;/li&gt;
&lt;li&gt;Poll delivery state with bounded retries and update the audit record.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No magic here.&lt;/p&gt;

&lt;p&gt;The absence of webhook events in Infrai's email and SMS namespaces means the poller is part of the architecture, not a temporary patch. Choose a polling interval from the business deadline, apply jitter across jobs, stop at a documented terminal state from the live discovery schema, and surface stale status separately from failed delivery. Don't invent a terminal-state list from memory; resolve the schema during integration and pin contract tests to what the discovery surface publishes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What failure boundaries separate the two viable system shapes?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Operational shape&lt;/th&gt;
&lt;th&gt;Compliance evidence fit&lt;/th&gt;
&lt;th&gt;Honest boundary&lt;/th&gt;
&lt;th&gt;Choose it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Infrai unified API&lt;/td&gt;
&lt;td&gt;One key and one bill cover the email and SMS transport surfaces&lt;/td&gt;
&lt;td&gt;Sender/signature management plus pollable SMS status can feed a small audit ledger&lt;/td&gt;
&lt;td&gt;No webhook events, built-in geo-fence, country-price kill switch, tag-aggregated cost report, voice, WhatsApp, or RCS&lt;/td&gt;
&lt;td&gt;A startup wants a narrow outbound alert path and accepts application-owned policy and polling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio direct&lt;/td&gt;
&lt;td&gt;A dedicated messaging integration&lt;/td&gt;
&lt;td&gt;Its published US A2P 10DLC documentation gives the team a direct source for that registration program&lt;/td&gt;
&lt;td&gt;The team still has to prove how report email and internal evidence join the SMS record&lt;/td&gt;
&lt;td&gt;US A2P 10DLC guidance and a specialist relationship dominate the decision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vonage direct&lt;/td&gt;
&lt;td&gt;A separate specialist adapter and account&lt;/td&gt;
&lt;td&gt;Treat registration artifacts and receipts as inputs to the same application audit ledger&lt;/td&gt;
&lt;td&gt;No supplied evidence here resolves its exact country coverage or contract terms&lt;/td&gt;
&lt;td&gt;Its current documentation and contract pass the startup's destination-by-destination review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sinch direct&lt;/td&gt;
&lt;td&gt;A separate specialist adapter and account&lt;/td&gt;
&lt;td&gt;Treat sender approval and delivery observations as evidence, never as the complete compliance decision&lt;/td&gt;
&lt;td&gt;No supplied evidence here resolves its exact country coverage or contract terms&lt;/td&gt;
&lt;td&gt;Its current documentation and contract fit the required destinations better&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SendGrid plus an SMS provider&lt;/td&gt;
&lt;td&gt;Split report email from the specialist SMS adapter&lt;/td&gt;
&lt;td&gt;Join the email submission record and SMS receipt in the application's ledger&lt;/td&gt;
&lt;td&gt;Two provider contracts, credentials, and evidence formats remain operationally separate&lt;/td&gt;
&lt;td&gt;The report-email branch deserves an independent vendor decision from SMS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This table is deliberately uneven. Twilio has a cited US A2P 10DLC source in this review; Vonage, Sinch, and a split SendGrid arrangement remain real shortlist candidates, but naming them is not evidence of a feature. Before selection, ask each vendor for the current registration workflow, the meaning and retention of delivery states, supported sender types by destination, data residency terms, and an export path for audit records. Marketing feature grids don't answer those questions.&lt;/p&gt;

&lt;p&gt;Architecture A, the unified control plane, has a compact credential and billing boundary. Its main failure mode is stale evidence: if polling stops, messages may continue to exist at the provider while the application's audit view freezes. Alert on poll age, make status refresh safe to repeat, and show “unknown” rather than converting absent observations into failure.&lt;/p&gt;

&lt;p&gt;Architecture B, direct specialist integrations, trades a wider operational surface for a closer provider relationship. Its failure mode is divergence: one adapter maps statuses differently, one team rotates a key without updating a worker, or the report-email record cannot be joined to the SMS receipt. A canonical internal evidence model and contract tests are mandatory either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The minimal Python evidence poller
&lt;/h2&gt;

&lt;p&gt;This runnable Python program retrieves the status of an existing SMS message. It uses the verified &lt;code&gt;GET /v1/sms/status/{id}&lt;/code&gt; route, keeps the API key in the environment, sets the method explicitly, honors &lt;code&gt;Retry-After&lt;/code&gt; on &lt;code&gt;429&lt;/code&gt;, applies exponential backoff otherwise, and surfaces every non-success body. It does not assume undocumented response fields.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.error&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;


&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;SMS_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SMS_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.infrai.cc/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retry_delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;pass&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;30.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_sms_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;message_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SMS_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/sms/status/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTTP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTTP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;retry_delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SMS status retry budget exhausted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;get_sms_status&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it after the submission worker has safely stored the provider message ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ifr_replace_with_your_key'&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SMS_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'replace_with_the_message_id'&lt;/span&gt;
python sms_status.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The submission side still needs an application idempotency key derived from the dispatch event, because a retry must not send the technician the same alert twice. The sample stays on status retrieval because the available evidence here does not specify the SMS send request body; making up fields would produce code that looks complete and teaches the wrong contract. Use live discovery to generate and validate that request in the implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the direct-provider option remains valid
&lt;/h2&gt;

&lt;p&gt;Rejecting direct integrations as “too much work” would be careless. Stick with Twilio when its direct US A2P 10DLC documentation and provider relationship are central to the compliance review. Keep Vonage or Sinch in the evaluation when their current written terms, destination support, and registration path fit the startup's exact traffic better. Pairing SendGrid with a specialist SMS provider is valid when report email needs its own vendor boundary. A specialist is also the better shape when the compliance team needs richer analytics or the product roadmap requires channels that Infrai does not support.&lt;/p&gt;

&lt;p&gt;The unified option wins only under narrower invariants: outbound alerts are straightforward, polling is timely enough, country policy lives in the app, and the team benefits from using one credential and one invoice across report email and SMS. Your mileage may vary — especially once a startup expands from a known US/EU destination allowlist into international traffic that changes faster than its release cycle.&lt;/p&gt;

&lt;p&gt;Whichever option survives, test the evidence chain rather than the happy-path UI. Prove that an unapproved destination is denied, a missing sender approval stops submission, duplicate dispatch events collapse to one send, &lt;code&gt;429&lt;/code&gt; delays retries, stale polling becomes visible, and the report-email record can be joined to the SMS alert record without copying sensitive report contents into the text message.&lt;/p&gt;

&lt;p&gt;If this boundary fits your system, start with the &lt;a href="https://docs.infrai.cc/en/guides/sms/answers/sms-alerts-api-with-sender-id-registration-us-eu-compli/" rel="noopener noreferrer"&gt;SMS alerts and registered sender guide&lt;/a&gt; and verify the live discovery schema before implementing submission.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://support.google.com/a/answer/81126" rel="noopener noreferrer"&gt;Google: Email sender guidelines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.twilio.com/docs/messaging/compliance/a2p-10dlc" rel="noopener noreferrer"&gt;Twilio: US A2P 10DLC compliance documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>sms</category>
      <category>fastapi</category>
      <category>compliance</category>
    </item>
  </channel>
</rss>
