<?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: Lucy </title>
    <description>The latest articles on DEV Community by Lucy  (@lucy1).</description>
    <link>https://dev.to/lucy1</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%2F1790752%2F3de53444-41e1-423d-843a-7e3727c1f878.png</url>
      <title>DEV Community: Lucy </title>
      <link>https://dev.to/lucy1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lucy1"/>
    <language>en</language>
    <item>
      <title>Why AI Projects Fail Between Prototype and Production</title>
      <dc:creator>Lucy </dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:26:49 +0000</pubDate>
      <link>https://dev.to/lucy1/why-ai-projects-fail-between-prototype-and-production-1ogl</link>
      <guid>https://dev.to/lucy1/why-ai-projects-fail-between-prototype-and-production-1ogl</guid>
      <description>&lt;p&gt;An AI prototype can work perfectly during a demo and still fail when real users start using it.&lt;/p&gt;

&lt;p&gt;The model returns accurate answers. The chatbot responds quickly. The AI agent completes its assigned task. Everything looks promising until the application encounters outdated data, unexpected requests, API failures, or hundreds of users at once.&lt;/p&gt;

&lt;p&gt;This is where many AI projects run into trouble.&lt;/p&gt;

&lt;p&gt;The problem is not always the model. More often, it is the engineering required to make that model work reliably inside a real application.&lt;/p&gt;

&lt;p&gt;Moving AI from prototype to production means solving challenges involving data quality, integrations, evaluation, security, performance, and monitoring.&lt;/p&gt;

&lt;p&gt;This challenge also shapes the work of LucentAI, Lucent Innovation’s dedicated AI engineering division. Its scope brings AI strategy, LLM and agent development, data engineering, integrations, and production operations together, rather than treating a successful model demo as a finished product.&lt;/p&gt;

&lt;p&gt;Let's explore why these failures happen and what developers can do to prevent them.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR: Why AI Projects Fail in Production
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unreliable data:&lt;/strong&gt; Production data is often incomplete, outdated, or inconsistent with test data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weak evaluation:&lt;/strong&gt; A successful demo does not prove that an AI system handles unexpected inputs correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration failures:&lt;/strong&gt; External APIs, databases, and business applications introduce additional failure points.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance and cost issues:&lt;/strong&gt; Latency and inference costs can increase as usage grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security gaps:&lt;/strong&gt; AI systems may expose sensitive information or perform unauthorized actions without proper controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing monitoring:&lt;/strong&gt; Without observability, teams struggle to identify errors and maintain output quality.
The solution is to treat AI as a complete software system, not just a model connected to an API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6mnemokj6j4shb9dkwy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6mnemokj6j4shb9dkwy.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Working AI Prototype Is Not Production-Ready
&lt;/h2&gt;

&lt;p&gt;A prototype is designed to answer an important question: &lt;em&gt;Can this idea work?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Production systems must answer a different question: &lt;em&gt;Can this application work reliably under real operating conditions?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Consider a company developing an AI-powered customer support assistant.&lt;/p&gt;

&lt;p&gt;During development, the team connects an LLM to a small collection of support documents using retrieval-augmented generation (RAG).&lt;/p&gt;

&lt;p&gt;The assistant answers questions correctly during testing. It retrieves relevant documents and produces useful responses.&lt;/p&gt;

&lt;p&gt;The prototype gets approved.&lt;/p&gt;

&lt;p&gt;But after deployment, the assistant begins receiving requests about outdated policies, missing orders, account-specific information, and situations that were never included in the original tests.&lt;/p&gt;

&lt;p&gt;Some users ask multiple questions simultaneously. Others submit incomplete information. Occasionally, the CRM API becomes unavailable.&lt;/p&gt;

&lt;p&gt;The system now has to handle much more than generating text.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxejrswyoop52u9jxvers.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxejrswyoop52u9jxvers.png" alt=" " width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A prototype demonstrates feasibility. Production readiness requires evidence that the complete system can operate within acceptable quality, performance, security, and cost limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  6 Reasons AI Projects Fail Between Prototype and Production
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Production Data Is Different From Test Data
&lt;/h3&gt;

&lt;p&gt;AI applications often perform well with carefully prepared development datasets.&lt;/p&gt;

&lt;p&gt;Real business data is rarely that clean.&lt;/p&gt;

&lt;p&gt;It may contain duplicate records, missing fields, outdated documents, inconsistent formats, or conflicting information.&lt;/p&gt;

&lt;p&gt;For RAG applications, data quality directly affects what the retrieval system can find and pass to the language model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An AI support assistant uses a vector database containing company return policies.&lt;/p&gt;

&lt;p&gt;During testing, it retrieves the correct policy and answers customer questions accurately.&lt;/p&gt;

&lt;p&gt;After deployment, the business updates its return policy. However, the document ingestion pipeline does not refresh the vector index.&lt;/p&gt;

&lt;p&gt;The assistant continues retrieving the previous policy.&lt;/p&gt;

&lt;p&gt;The LLM may produce a fluent, well-structured answer, but the information is no longer correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Developers may focus on embeddings, chunking, vector search, and prompt design while overlooking the operational lifecycle of the underlying data.&lt;/p&gt;

&lt;p&gt;A successful retrieval test does not guarantee that the information will remain accurate.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to prevent it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Validate data quality before indexing.&lt;/li&gt;
&lt;li&gt;Track document versions and ingestion timestamps.&lt;/li&gt;
&lt;li&gt;Define refresh schedules or event-driven updates.&lt;/li&gt;
&lt;li&gt;Test retrieval with representative production questions.&lt;/li&gt;
&lt;li&gt;Detect missing or outdated source documents.&lt;/li&gt;
&lt;li&gt;Preserve source references for generated answers.
For RAG systems, evaluation should include both retrieval quality and the accuracy of the final response.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A better prompt cannot reliably compensate for missing or outdated information.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. AI Outputs Are Not Evaluated Against Real Use Cases
&lt;/h2&gt;

&lt;p&gt;One of the most common weaknesses in AI prototypes is limited testing.&lt;/p&gt;

&lt;p&gt;A developer submits a few questions, reviews the responses, adjusts the prompt, and concludes that the application works.&lt;/p&gt;

&lt;p&gt;This approach is useful during exploration, but it is not a reliable production evaluation strategy.&lt;/p&gt;

&lt;p&gt;LLM outputs can vary across inputs, prompt versions, model versions, and retrieved context.&lt;/p&gt;

&lt;p&gt;A system that performs well on ten manually selected questions may still fail on hundreds of realistic scenarios.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An AI assistant is asked:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Can I return an item purchased during a clearance sale?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The retrieved policy says clearance items are final sale, except when a product arrives damaged.&lt;/p&gt;

&lt;p&gt;The assistant answers that clearance items cannot be returned under any circumstances.&lt;/p&gt;

&lt;p&gt;The response sounds confident but misses an important exception.&lt;/p&gt;

&lt;p&gt;This is not necessarily a retrieval failure. The correct document may have been available, but the generated answer was incomplete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this happens&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Teams sometimes evaluate whether a response sounds reasonable rather than checking whether it is supported by the available evidence and satisfies the task requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to prevent it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Create an evaluation dataset representing actual application behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Include:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Common questions and expected answers&lt;/li&gt;
&lt;li&gt;Ambiguous or incomplete requests&lt;/li&gt;
&lt;li&gt;Questions requiring multiple documents&lt;/li&gt;
&lt;li&gt;Cases where relevant information is unavailable&lt;/li&gt;
&lt;li&gt;Conflicting or outdated source material&lt;/li&gt;
&lt;li&gt;Requests the assistant should refuse or escalate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Track metrics relevant to the application, such as answer correctness, groundedness, retrieval relevance, and task completion.&lt;/p&gt;

&lt;p&gt;Automated checks can help identify regressions, but high-impact scenarios may still require human review.&lt;/p&gt;

&lt;p&gt;Run evaluations whenever prompts, retrieval settings, models, or important data sources change.&lt;/p&gt;

&lt;p&gt;The goal is not to achieve perfect responses on every possible input. It is to measure whether the system meets defined quality requirements and identify situations where it must fail safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Enterprise Integrations Introduce Unexpected Failures
&lt;/h2&gt;

&lt;p&gt;Most useful enterprise AI applications do more than generate answers.&lt;/p&gt;

&lt;p&gt;They retrieve information from databases, query CRMs, access inventory systems, create support tickets, or trigger business workflows.&lt;/p&gt;

&lt;p&gt;Every integration introduces another possible failure point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;br&gt;
An AI assistant receives a request to check an order status.&lt;/p&gt;

&lt;p&gt;The expected workflow is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Identify the order&lt;/li&gt;
&lt;li&gt;Verify that the user is authorized to access it&lt;/li&gt;
&lt;li&gt;Request order information from the CRM&lt;/li&gt;
&lt;li&gt;Interpret the returned status&lt;/li&gt;
&lt;li&gt;Respond to the user&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But what happens when the CRM API takes too long to respond?&lt;/p&gt;
&lt;h3&gt;
  
  
  What if it returns an error?
&lt;/h3&gt;

&lt;p&gt;What if the API accepts a ticket-creation request but the network connection fails before the response arrives?&lt;/p&gt;

&lt;p&gt;Without proper controls, the assistant may return incorrect information or repeat an operation that has already succeeded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this happens&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Developers sometimes treat AI tool calls like ordinary function calls without accounting for distributed-system failures.&lt;/p&gt;

&lt;p&gt;However, an LLM selecting a tool does not guarantee that the external operation will succeed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to prevent it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Build resilience into the application layer.&lt;/p&gt;

&lt;p&gt;Use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request timeouts for external services.&lt;/li&gt;
&lt;li&gt;Bounded retries with backoff for safely retryable operations.&lt;/li&gt;
&lt;li&gt;Idempotency keys for supported write operations.&lt;/li&gt;
&lt;li&gt;Input validation before executing tools.&lt;/li&gt;
&lt;li&gt;Authorization checks independent of the LLM.&lt;/li&gt;
&lt;li&gt;Structured error responses and safe fallbacks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Consider this simplified Python example for a read-only CRM request:&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;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;requests.adapters&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTTPAdapter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib3.util.retry&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Retry&lt;/span&gt;

&lt;span class="n"&gt;retry_policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;total&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;span class="n"&gt;backoff_factor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status_forcelist&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;allowed_methods&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;GET&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="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mount&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://&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;HTTPAdapter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;retry_policy&lt;/span&gt;&lt;span class="p"&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;get_order_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;access_token&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="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://crm.example.com/api/orders/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;order_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;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;access_token&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;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;raise_for_status&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;except&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestException&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;unavailable&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;message&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;Order information is temporarily unavailable.&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;This illustrative example introduces connection and read timeouts, limited retries, and controlled error handling.&lt;/p&gt;

&lt;p&gt;It is not a complete production implementation. The calling application must still validate the order identifier, verify authorization, manage credentials securely, and distinguish a genuine business status from an infrastructure error.&lt;/p&gt;

&lt;p&gt;In particular, the assistant should never translate "unavailable" into a fabricated order status.&lt;/p&gt;

&lt;p&gt;For write operations, retries need additional safeguards because a failed response does not always mean the operation failed.&lt;/p&gt;

&lt;p&gt;The key principle is simple: &lt;strong&gt;the AI model can help decide which operation to request, but the application must control how that operation is executed.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Performance and Inference Costs Become Unpredictable
&lt;/h2&gt;

&lt;p&gt;A prototype may serve only a few requests during testing.&lt;/p&gt;

&lt;p&gt;A production application can receive many concurrent requests, each consuming different amounts of compute, tokens, and external resources.&lt;/p&gt;

&lt;p&gt;This introduces two challenges: performance and operating costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;br&gt;
An internal knowledge assistant performs well when answering short questions.&lt;/p&gt;

&lt;p&gt;Later, users begin submitting long documents and complex questions requiring multiple retrieval and tool-calling steps.&lt;/p&gt;

&lt;p&gt;Each request may now involve:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generating embeddings&lt;/li&gt;
&lt;li&gt;Searching a vector database&lt;/li&gt;
&lt;li&gt;Retrieving several documents&lt;/li&gt;
&lt;li&gt;Constructing a larger model context&lt;/li&gt;
&lt;li&gt;Making one or more LLM calls&lt;/li&gt;
&lt;li&gt;Processing additional tool responses
The system becomes slower, and the cost per request increases&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Some responses may finish quickly while others take significantly longer.&lt;/p&gt;

&lt;p&gt;Why this happens&lt;/p&gt;

&lt;p&gt;Prototype tests often focus on whether the model produces the correct answer.&lt;/p&gt;

&lt;p&gt;They may not measure system behavior under sustained traffic or complex request patterns.&lt;/p&gt;

&lt;p&gt;How to prevent it&lt;/p&gt;

&lt;p&gt;Measure performance at the application level, not only at the model level.&lt;/p&gt;

&lt;p&gt;Useful metrics include:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fskrskrwl3af2eryrykrx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fskrskrwl3af2eryrykrx.png" alt=" " width="799" height="342"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Developers can also consider caching appropriate responses, limiting context size, batching suitable workloads, and routing simpler tasks to less expensive models.&lt;/p&gt;

&lt;p&gt;However, each optimization should be tested against output quality.&lt;/p&gt;

&lt;p&gt;For example, reducing retrieved context may improve latency while removing information needed for accurate answers.&lt;/p&gt;

&lt;p&gt;The objective is not simply to make AI cheaper or faster. It is to maintain acceptable quality within defined performance and cost limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Security and Access Controls Are Added Too Late
&lt;/h2&gt;

&lt;p&gt;AI applications introduce security risks when models can access enterprise information or interact with business tools.&lt;/p&gt;

&lt;p&gt;One important issue is prompt injection.&lt;/p&gt;

&lt;p&gt;An attacker may place malicious instructions inside a document, webpage, or tool response that the AI application processes.&lt;/p&gt;

&lt;p&gt;If the application treats that content as trusted instructions, the model may attempt an unauthorized action.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
An AI assistant retrieves a customer support document containing instructions that tell the assistant to ignore its original task and send account information to an external endpoint.&lt;/p&gt;

&lt;p&gt;The document should be treated as untrusted content.&lt;/p&gt;

&lt;p&gt;However, an insecure application may allow those instructions to influence tool selection or downstream actions.&lt;/p&gt;

&lt;p&gt;This becomes especially dangerous when an AI agent has broad permissions across business systems.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency/" rel="noopener noreferrer"&gt;OWASP guidance on excessive agency&lt;/a&gt; highlights risks associated with excessive functionality, permissions, and autonomy in LLM-based applications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this happens&lt;/strong&gt;&lt;br&gt;
Developers may assume that system prompts are sufficient to control model behavior.&lt;/p&gt;

&lt;p&gt;But prompts alone are not reliable security boundaries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to prevent it&lt;/strong&gt;&lt;br&gt;
Enforce security controls outside the model.&lt;/p&gt;

&lt;p&gt;These should include:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Least-privilege tool permissions&lt;/li&gt;
&lt;li&gt;Server-side authentication and authorization&lt;/li&gt;
&lt;li&gt;Strict validation of tool arguments&lt;/li&gt;
&lt;li&gt;Separation of trusted instructions from retrieved content&lt;/li&gt;
&lt;li&gt;Approval steps for sensitive or irreversible actions&lt;/li&gt;
&lt;li&gt;Restrictions on external network destinations&lt;/li&gt;
&lt;li&gt;Audit logs for important tool executions
An AI assistant that can read order information should not automatically receive permission to modify orders, issue refunds, or export customer records.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every action should be governed by application-level permissions and business rules.&lt;/p&gt;

&lt;p&gt;Security is not a final prompt-engineering task. It is part of the system architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Nobody Owns Monitoring and Reliability After Deployment
&lt;/h2&gt;

&lt;p&gt;Deploying an AI application is not the end of its engineering lifecycle.&lt;/p&gt;

&lt;p&gt;Models change. Business documents are updated. APIs behave differently. User requests evolve.&lt;/p&gt;

&lt;p&gt;Without monitoring, these changes can gradually reduce application quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An AI support assistant initially retrieves relevant documents and resolves common customer questions.&lt;/p&gt;

&lt;p&gt;Over time, the business introduces new products and changes its support documentation.&lt;/p&gt;

&lt;p&gt;The retrieval pipeline continues running, but the assistant starts producing less relevant responses.&lt;/p&gt;

&lt;p&gt;There may be no visible infrastructure outage.&lt;/p&gt;

&lt;p&gt;The application is technically available, but its usefulness is declining.&lt;/p&gt;

&lt;p&gt;Traditional uptime monitoring alone may not detect this problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this happens&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Development teams often monitor server health and API availability without tracking AI-specific behavior.&lt;/p&gt;

&lt;p&gt;Yet an AI application can return HTTP 200 responses while producing incorrect or unsupported answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to prevent it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Monitor multiple layers of the system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure monitoring&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Track service availability, request failures, latency, and resource utilization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model and application monitoring&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Measure token usage, model errors, response quality, and relevant evaluation metrics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval monitoring&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Track document freshness, retrieval failures, and changes in retrieval relevance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool execution monitoring&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Record tool-call outcomes, authorization failures, timeouts, and retries.&lt;/p&gt;

&lt;p&gt;Logging and tracing should use appropriate redaction and access controls to avoid exposing sensitive information.&lt;/p&gt;

&lt;p&gt;Teams should also establish ownership for alerts, investigate recurring failures, and maintain rollback procedures for model, prompt, or application changes.&lt;/p&gt;

&lt;p&gt;A production AI system needs the same operational discipline as other business-critical software, along with monitoring that accounts for AI-specific behavior.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6aeogma4ytyk0olh3j1d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6aeogma4ytyk0olh3j1d.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Framework for Moving AI From Prototype to Production
&lt;/h2&gt;

&lt;p&gt;The six failure scenarios point to a common issue: teams often validate the model before validating the entire application.&lt;/p&gt;

&lt;p&gt;A more reliable approach is to introduce production requirements earlier in development.&lt;/p&gt;

&lt;p&gt;The following five-stage framework provides a practical starting point.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq42jvjehne419do5zu9l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq42jvjehne419do5zu9l.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Define Production Success Criteria
&lt;/h3&gt;

&lt;p&gt;Before building the complete application, define what success means.&lt;/p&gt;

&lt;p&gt;This might include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Minimum acceptable answer quality&lt;/li&gt;
&lt;li&gt;Maximum response latency under expected load&lt;/li&gt;
&lt;li&gt;Acceptable operating cost per task&lt;/li&gt;
&lt;li&gt;Supported business actions&lt;/li&gt;
&lt;li&gt;Security and access requirements&lt;/li&gt;
&lt;li&gt;Expected behavior when dependencies fail&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Deliverable:&lt;/strong&gt; A documented set of measurable acceptance criteria&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Test With Realistic Data and Failure Cases
&lt;/h3&gt;

&lt;p&gt;Move beyond curated test prompts.&lt;/p&gt;

&lt;p&gt;Evaluate the application using representative data, edge cases, missing information, and unexpected requests.&lt;/p&gt;

&lt;p&gt;Include negative scenarios such as unavailable documents, incorrect identifiers, and conflicting records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deliverable:&lt;/strong&gt; A versioned evaluation dataset and repeatable tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Engineer Reliable Integrations
&lt;/h3&gt;

&lt;p&gt;Treat databases, external APIs, and AI tools as systems that may fail independently.&lt;/p&gt;

&lt;p&gt;Implement authorization, validation, timeouts, bounded retries, and recovery behavior.&lt;/p&gt;

&lt;p&gt;Separate the model's proposed actions from the application's execution permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deliverable:&lt;/strong&gt; Tested integration contracts and documented failure-handling behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Validate Security, Performance, and Costs
&lt;/h3&gt;

&lt;p&gt;Test the application under realistic workloads.&lt;/p&gt;

&lt;p&gt;Review tool permissions, sensitive-data handling, prompt-injection risks, inference costs, and response latency.&lt;/p&gt;

&lt;p&gt;Use the results to identify constraints before a wider rollout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deliverable:&lt;/strong&gt; Performance, security, and cost validation reports.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Deploy Gradually and Monitor Continuously
&lt;/h3&gt;

&lt;p&gt;Begin with a limited rollout where appropriate.&lt;/p&gt;

&lt;p&gt;Monitor production requests, investigate failures, and compare observed behavior with the original acceptance criteria.&lt;/p&gt;

&lt;p&gt;Maintain an operational process for updating prompts, models, retrieval pipelines, and external integrations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deliverable:&lt;/strong&gt; Monitoring dashboards, alerting rules, rollback procedures, and assigned operational ownership.&lt;/p&gt;

&lt;p&gt;The important lesson is that production readiness cannot be delivered by improving prompts alone. It requires the entire application to behave reliably under realistic conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where LucentAI Fits Into the Prototype-to-Production Challenge
&lt;/h2&gt;

&lt;p&gt;The six problems above are connected. A RAG application can have a capable model but fail because its data pipelines are stale. An AI agent can choose the correct tool but still create operational risk if application permissions are too broad. Better prompting alone will not fix either issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8o3s8onbq99v8x1kxme.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8o3s8onbq99v8x1kxme.png" alt=" " width="690" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LucentAI is Lucent Innovation's AI engineering division&lt;/strong&gt;, bringing several parts of the delivery process into one team:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI readiness and strategy: Evaluate the business use case, technical constraints, success criteria, and a practical route from proof of concept to implementation.&lt;/li&gt;
&lt;li&gt;Data engineering: Prepare reliable data sources and pipelines for retrieval, analytics, machine learning, and production AI applications, including work within the Databricks ecosystem.&lt;/li&gt;
&lt;li&gt;LLMs, RAG, and AI agents: Design applications around real workflows, enterprise knowledge, model behavior, and integration boundaries.&lt;/li&gt;
&lt;li&gt;Application integration: Connect AI components with existing business applications and controlled APIs rather than leaving them isolated in a demo environment.&lt;/li&gt;
&lt;li&gt;Production engineering: Plan deployment, evaluation, monitoring, and optimization alongside the core AI application.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That combined approach is relevant when a team has already validated an AI idea but needs to make the complete system dependable. The details still depend on the use case: an internal document assistant and an AI agent permitted to update business records do not need identical safeguards.&lt;/p&gt;

&lt;p&gt;Explore &lt;a href="https://www.lucentinnovation.com/lucent-ai" rel="noopener noreferrer"&gt;LucentAI's AI engineering and data capabilities&lt;/a&gt; for more on the division's approach to moving AI work into production.&lt;/p&gt;

&lt;p&gt;The practical takeaway for developers is to assign ownership to every layer, from source-data freshness to tool permissions and post-release monitoring. A gap between those layers is often where a promising prototype stops being a reliable application.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Know Whether an AI System Is Ready for Production
&lt;/h2&gt;

&lt;p&gt;There is no universal checklist that guarantees production readiness for every AI application.&lt;/p&gt;

&lt;p&gt;A customer support chatbot, financial document processor, and autonomous workflow agent have different risk profiles.&lt;/p&gt;

&lt;p&gt;However, developers can assess readiness across several common engineering areas.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fngp1ug6fos9hx1vy23cq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fngp1ug6fos9hx1vy23cq.png" alt=" " width="733" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These questions should be answered with test results and documented evidence wherever possible.&lt;/p&gt;

&lt;p&gt;For example, instead of declaring an assistant "fast enough," measure its P95 response latency under expected traffic.&lt;/p&gt;

&lt;p&gt;Instead of claiming that an agent is secure because its system prompt contains restrictions, test whether application-level controls prevent unauthorized operations.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.nist.gov/itl/ai-risk-management-framework" rel="noopener noreferrer"&gt;NIST AI Risk Management Framework&lt;/a&gt; also provides a useful reference for organizations evaluating and managing AI-related risks throughout the system lifecycle.&lt;/p&gt;

&lt;p&gt;Production readiness is not a single deployment milestone. It is an ongoing process of verification, monitoring, and improvement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Final Verdict Says Is:
&lt;/h2&gt;

&lt;p&gt;Production Success Requires More Than a Working Model&lt;br&gt;
AI prototypes are valuable because they help teams validate ideas quickly.&lt;/p&gt;

&lt;p&gt;But a successful demonstration does not automatically translate into a dependable production application.&lt;/p&gt;

&lt;p&gt;Real environments introduce changing data, unpredictable requests, external dependencies, security requirements, and operational costs.&lt;/p&gt;

&lt;p&gt;Solving these challenges requires more than selecting a capable LLM or improving a prompt.&lt;/p&gt;

&lt;p&gt;It requires deliberate software engineering across the entire AI system.&lt;/p&gt;

&lt;p&gt;For developers, the most useful mindset is to &lt;strong&gt;build for failure, measure actual behavior, and design recovery into the application from the beginning.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A successful AI project is not simply one that produces impressive answers during testing. It is one that continues delivering reliable results when real users, real data, and real-world failures become part of the system.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>How to Fix Shopify POS and EDC Terminal Reconciliation Mismatches published</title>
      <dc:creator>Lucy </dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:55:53 +0000</pubDate>
      <link>https://dev.to/lucy1/how-to-fix-shopify-pos-and-edc-terminal-reconciliation-mismatches-published-3e</link>
      <guid>https://dev.to/lucy1/how-to-fix-shopify-pos-and-edc-terminal-reconciliation-mismatches-published-3e</guid>
      <description>&lt;h2&gt;
  
  
  The problem: two ledgers, one sale
&lt;/h2&gt;

&lt;p&gt;A cashier taps "Pay by card" in Shopify POS. The customer pays on the EDC terminal. Shopify records a sale. The terminal records a charge. Later, the bank settles the money.&lt;/p&gt;

&lt;p&gt;That is three records for one sale. If any one of them is off, your end-of-day numbers do not match.&lt;/p&gt;

&lt;p&gt;We see this a lot in multi-store retail projects. The totals are off by a few hundred rupees, or a few dollars. Nobody knows which order caused it. Someone then spends hours with two spreadsheets.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkun944uyghfnlutoh81v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkun944uyghfnlutoh81v.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the numbers drift
&lt;/h2&gt;

&lt;p&gt;Most mismatches come from four cases. Knowing them makes the fix easier to design.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Partial payments.&lt;/strong&gt;A customer pays half by card and half by cash. The terminal shows only the card part. Shopify shows one order with two payments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refunds and voids.&lt;/strong&gt; A refund is started on the terminal but not recorded in Shopify, or the other way around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settlement delays.&lt;/strong&gt; The terminal batch closes at night. The bank pays out one to three days later. A daily report compares two days that do not line up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline or retried transactions.&lt;/strong&gt; The network drops. The cashier retries. The terminal charges once, but Shopify gets two attempts, or none.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what these have in common. Each one breaks a match that relies on amount and time alone. Two sales of the same amount in the same minute look identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The approach: match on a shared reference
&lt;/h2&gt;

&lt;p&gt;So the first rule is simple. Never match by amount. Match by a reference that both systems can see.&lt;/p&gt;

&lt;p&gt;Most EDC terminals print a retrieval reference number (RRN) and an auth code on every receipt. Those values are unique per transaction. If you save them on the Shopify order, you get a key that joins all three records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Here is the plan in four parts:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Capture the RRN and auth code at the time of payment.&lt;/li&gt;
&lt;li&gt;Save them on the Shopify order.&lt;/li&gt;
&lt;li&gt;Pull terminal and bank reports on a schedule.&lt;/li&gt;
&lt;li&gt;Match by reference and flag what is left.
Let's build each part.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: Capture the reference at payment time
&lt;/h2&gt;

&lt;p&gt;The cleanest option is to have the cashier flow pass the terminal response back to your app. If your terminal vendor has an SDK or API, use it to read the RRN and auth code right after approval.&lt;/p&gt;

&lt;p&gt;If your terminal cannot talk to your app, fall back to a short manual field. Ask the cashier to type the last digits of the RRN. This is slower and has typos, so treat it as a second choice.&lt;/p&gt;

&lt;p&gt;Either way, the goal is the same: one reference per card payment, attached to the order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Save the reference on the Shopify order
&lt;/h2&gt;

&lt;p&gt;Store the values as order metafields so they are easy to query later. Here is a minimal example using the Admin GraphQL API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
  mutation SetEdcRef($metafields: [MetafieldsSetInput!]!) {
    metafieldsSet(metafields: $metafields) {
      metafields { id key value }
      userErrors { field message }
    }
  }
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveEdcReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orderGid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rrn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;variables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;metafields&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="na"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orderGid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;edc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rrn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;single_line_text_field&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rrn&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orderGid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;edc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth_code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;single_line_text_field&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;authCode&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;variables&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metafieldsSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userErrors&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&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;Always check &lt;code&gt;userErrors&lt;/code&gt;. A silent failure here means an unmatched order later.&lt;/p&gt;

&lt;p&gt;Also listen for the webhooks that change the money on an order. At minimum, subscribe to orders/create and refunds/create. When a webhook arrives, verify the HMAC signature before you trust it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isValidShopifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hmacHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hmacHeader&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;Keep secrets in environment variables, never in source code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Pull terminal and bank data on a schedule
&lt;/h2&gt;

&lt;p&gt;Most acquirers and terminal vendors offer a daily settlement file or an API. Pull it once a day. Save each row with its RRN, amount, status, and settlement date.&lt;/p&gt;

&lt;p&gt;Two tips that save pain later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Store raw files first.&lt;/strong&gt; Keep the original file before you parse it. When a row looks wrong, you can go back to the source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the import safe to run twice.&lt;/strong&gt; Use the RRN as a unique key so a rerun does not create duplicates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: Match by reference and sort the results
&lt;/h2&gt;

&lt;p&gt;Now the matching job is small. For each terminal row, look for a Shopify order with the same RRN. Then compare the amount.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;terminalRows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shopifyOrders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byRrn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shopifyOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rrn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;amountMismatch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;missingInShopify&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="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;terminalRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;byRrn&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="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rrn&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;missingInShopify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cardAmount&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amountMismatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nx"&gt;byRrn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rrn&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missingOnTerminal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;byRrn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;missingOnTerminal&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;This gives you four clear buckets:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15vtdlqisqqtj9dikcy6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15vtdlqisqqtj9dikcy6.png" alt=" " width="718" height="314"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Humans only look at the last three. On a normal day that is a short list, not a full spreadsheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle the tricky cases on purpose
&lt;/h2&gt;

&lt;p&gt;A few rules make the job much more reliable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use a settlement window.&lt;/strong&gt; A sale from Monday may settle on Wednesday. Do not flag it as missing on Tuesday. Wait for the expected settlement date, then flag it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare card amounts only.&lt;/strong&gt; For split payments, compare the card portion of the Shopify order, not the order total.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link refunds to the original sale.&lt;/strong&gt; Save the original RRN on the refund record. Then a refund is a second line on the same transaction, not a new mystery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log every change.&lt;/strong&gt; Keep an audit trail of who fixed what and when. Finance teams will ask for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protect customer data.&lt;/strong&gt; Never store full card numbers. The RRN and auth code are enough for matching. Keep personal data out of logs, and use fake data in test environments.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Make it safe to ship
&lt;/h2&gt;

&lt;p&gt;Roll this out in steps. Run the matching job in read-only mode first and compare its output with your current manual process for a week. Test on a staging store before you touch production. Use a feature flag so you can turn the sync off fast if something looks wrong.&lt;/p&gt;

&lt;p&gt;If your payment flow involves custom terminals or several stores, a proper &lt;a href="https://www.lucentinnovation.com/services/payment-gateway-integration" rel="noopener noreferrer"&gt;payment gateway integration&lt;/a&gt; design up front saves a lot of rework. We have built these kinds of flows at Lucent Innovation, and the pattern above is the one that has held up best.&lt;/p&gt;

&lt;p&gt;For background on the Shopify side of POS data, see the related post &lt;a href="https://www.lucentinnovation.com/resources/technology-posts/shopify-pos-edc-terminal-integration" rel="noopener noreferrer"&gt;Shopify POS + EDC Terminal Integration&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You can also check the &lt;a href="https://shopify.dev/docs/api/admin-graphql/latest" rel="noopener noreferrer"&gt;Shopify Admin API docs&lt;/a&gt; for the exact fields on orders, transactions, and metafields.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Why do my Shopify POS and EDC totals not match?&lt;/strong&gt;&lt;br&gt;
Because they are two separate records of the same sale. They drift when a partial payment, refund, delayed settlement, or offline retry is recorded on only one side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. What is the best key for matching card payments?&lt;/strong&gt;&lt;br&gt;
The RRN from the terminal. It is unique per transaction, and it appears on both the terminal report and the receipt. The auth code is a good second check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Can I reconcile without a terminal API?&lt;/strong&gt;&lt;br&gt;
Yes. Use the daily settlement file from your acquirer and save the RRN on each order, even by manual entry. It is slower, but it still beats matching by amount.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. How often should I run reconciliation?&lt;/strong&gt;&lt;br&gt;
Daily. Run it after the settlement file arrives, and use a short window so late settlements are not flagged too early.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. How do I handle split payments?&lt;/strong&gt;&lt;br&gt;
Compare only the card portion of the Shopify order with the terminal amount. Cash and other methods should be reconciled separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Reconciliation breaks when you match by amount and time. It works when every card payment carries a shared reference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To close the gap:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Capture the RRN and auth code at payment.&lt;br&gt;
Save them on the Shopify order as metafields.&lt;br&gt;
Pull terminal and settlement data daily.&lt;br&gt;
Match by reference and review only the exceptions.&lt;br&gt;
For brands running many stores, a &lt;a href="https://www.lucentinnovation.com/services/shopify-plus-development-agency" rel="noopener noreferrer"&gt;Shopify Plus development&lt;/a&gt; setup can make this easier to scale, since every store follows the same pattern.&lt;/p&gt;

&lt;p&gt;Start small, run it read-only for a week, and let the numbers show you where your gaps really are.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>shopifypos</category>
      <category>edcterminal</category>
    </item>
    <item>
      <title>Shopify Data in Databricks: A First Look at the New Lakeflow Connect Shopify Connector</title>
      <dc:creator>Lucy </dc:creator>
      <pubDate>Tue, 29 Sep 2026 12:25:03 +0000</pubDate>
      <link>https://dev.to/lucy1/shopify-data-in-databricks-a-first-look-at-the-new-lakeflow-connect-shopify-connector-g2l</link>
      <guid>https://dev.to/lucy1/shopify-data-in-databricks-a-first-look-at-the-new-lakeflow-connect-shopify-connector-g2l</guid>
      <description>&lt;p&gt;Databricks now has a managed Shopify connector in Lakeflow Connect, released in Beta on September 22, 2026. It pulls products, orders, customers, inventory, fulfillments, and discounts from a Shopify store into Unity Catalog tables with incremental syncs, so you no longer need to write and maintain your own API extraction code. It is a strong default for analytics on Shopify data, with a few Beta limitations you should plan around before relying on it in production.&lt;/p&gt;

&lt;p&gt;This guide covers how the connector fits into a lakehouse architecture, how to set it up end to end (Shopify app, Unity Catalog connection, and pipeline as code), what to build on top of the raw tables, and where a custom pipeline is still the better choice. Everything here is based on the official Databricks and Shopify documentation as of late September 2026. Because the connector is in Beta, check the linked docs for changes before you build on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the Lakeflow Connect Shopify connector actually do?
&lt;/h2&gt;

&lt;p&gt;The connector is a managed ingestion pipeline: you point it at a Shopify store, pick the tables you want, and Databricks handles extraction, pagination, rate limiting, incremental cursors, and writing to Delta tables. According to the &lt;a href="https://docs.databricks.com/aws/en/ingestion/lakeflow-connect/shopify" rel="noopener noreferrer"&gt;Shopify connector overview&lt;/a&gt;, it supports UI-based and API-based pipeline authoring, Declarative Automation Bundles, incremental ingestion, Unity Catalog governance, orchestration through Databricks Workflows, and SCD Type 2 history tracking.&lt;/p&gt;

&lt;p&gt;The pipeline documentation lists 39 source tables in a &lt;code&gt;default&lt;/code&gt; source schema. You can ingest individual tables or the whole schema in one declaration.&lt;/p&gt;

&lt;p&gt;What it deliberately does not do matters just as much. The limitations page is explicit that the connector ingests raw data without transformations and expects you to handle modeling downstream. It also doesn't cover custom metafields, Shopify reports, or objects outside its 39 prebuilt tables. If your store runs heavily on metafields, that is the first thing to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does it fit into a lakehouse architecture?
&lt;/h2&gt;

&lt;p&gt;The connector replaces the extraction layer, the part most Shopify data teams build by hand with Admin API calls, pagination logic, and retry handling. Everything after it stays the same: bronze tables land in Unity Catalog, you transform them into silver and gold layers, and BI tools, Genie, or ML workloads read from the gold layer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fll0z8be5cb703qu4rcrg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fll0z8be5cb703qu4rcrg.png" alt=" " width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two properties of this design are worth calling out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credentials live in Unity Catalog, not in code.&lt;/strong&gt; The Shopify client ID and secret are stored in a Unity Catalog connection object. Pipeline definitions reference the connection by name, so secrets never show up in notebooks, YAML files, or Git history. If you've ever found an access token hardcoded in an old extraction script, this is a real improvement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Governance applies from the first table.&lt;/strong&gt; Because the landing tables are Unity Catalog tables, the same access controls, lineage, and auditing you use elsewhere apply to Shopify data immediately. That matters for customer data, which we'll come back to in the limitations section.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do you need before you start?
&lt;/h2&gt;

&lt;p&gt;The setup splits across Shopify and Databricks, and it's common for a different person to own each side. Check these before starting.&lt;/p&gt;

&lt;p&gt;On the Databricks side, the &lt;a href="https://docs.databricks.com/gcp/en/ingestion/lakeflow-connect/shopify-pipeline" rel="noopener noreferrer"&gt;pipeline requirements&lt;/a&gt; are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A workspace enabled for Unity Catalog, with serverless compute turned on.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CREATE CONNECTION&lt;/code&gt; on the metastore to create the connection, or &lt;code&gt;USE CONNECTION&lt;/code&gt; to use an existing one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;USE CATALOG&lt;/code&gt; on the target catalog, plus &lt;code&gt;USE SCHEMA&lt;/code&gt; and &lt;code&gt;CREATE TABLE&lt;/code&gt; on the target schema (or &lt;code&gt;CREATE SCHEMA&lt;/code&gt; on the catalog).&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A workspace admin must enable &lt;strong&gt;Lakeflow Connect for Shopify&lt;/strong&gt; on the Previews page, since the connector is in Beta.&lt;br&gt;
On the Shopify side, per the &lt;a href="https://docs.databricks.com/aws/en/ingestion/lakeflow-connect/shopify-source-setup" rel="noopener noreferrer"&gt;authentication setup guide&lt;/a&gt;:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Any Shopify plan works, because the connector reads through the Shopify Admin API, which is available on all plans.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An app created in the Shopify Dev Dashboard, in the same Shopify organization as the store.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The &lt;code&gt;read_all_orders&lt;/code&gt; scope if you need order history older than 60 days. Shopify grants this scope by approval, so request it early. Without it, you only get the last 60 days of orders.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Shopify Payments on the store if you want the &lt;code&gt;balance_transactions&lt;/code&gt; and &lt;code&gt;disputes&lt;/code&gt; tables.&lt;br&gt;
The &lt;code&gt;read_all_orders&lt;/code&gt; approval is the step most likely to delay your project, because it depends on Shopify rather than on your team. If historical reporting is the goal, start that request before anything else.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you set up the connector step by step?
&lt;/h2&gt;

&lt;p&gt;Setup has three stages: create the Shopify app, create the Unity Catalog connection, and define the ingestion pipeline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn8om58p3sieqszlwwzx2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn8om58p3sieqszlwwzx2.png" alt=" " width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create and install the Shopify app
&lt;/h3&gt;

&lt;p&gt;The connector authenticates with the &lt;a href="https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant" rel="noopener noreferrer"&gt;Shopify OAuth client credentials grant&lt;/a&gt;, a machine-to-machine flow with no interactive login. In the Shopify Dev Dashboard:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create an app.&lt;/li&gt;
&lt;li&gt;On the app's version, select the read scopes required for the tables you plan to ingest. The Databricks &lt;a href="https://docs.databricks.com/aws/en/ingestion/lakeflow-connect/shopify-reference#required-scopes" rel="noopener noreferrer"&gt;required access scopes list&lt;/a&gt; shows what each table needs.&lt;/li&gt;
&lt;li&gt;Install the app on the store.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Settings&lt;/strong&gt;, copy the &lt;strong&gt;Client ID&lt;/strong&gt; and &lt;strong&gt;Client secret&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Note the store subdomain, which is the &lt;code&gt;&amp;lt;shop&amp;gt;&lt;/code&gt; part of &lt;code&gt;&amp;lt;shop&amp;gt;.myshopify.com&lt;/code&gt;.
Only grant the scopes for the tables you actually need. A read-only app with a narrow scope list limits the damage if credentials ever leak, and it makes the access easier to justify in a security review.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 2: Create the Unity Catalog connection
&lt;/h3&gt;

&lt;p&gt;Only grant the scopes for the tables you actually need. A read-only app with a narrow scope list limits the damage if credentials ever leak, and it makes the access easier to justify in a security review. If an agency manages the store's apps, involve them at this step, since they usually control the Dev Dashboard (for clients of &lt;a href="https://www.lucentinnovation.com/services/shopify-development-agency" rel="noopener noreferrer"&gt;Lucent's Shopify development services&lt;/a&gt;, that's our team).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Define the pipeline as code
&lt;/h3&gt;

&lt;p&gt;The UI wizard (&lt;strong&gt;Data Ingestion → Add data → Shopify&lt;/strong&gt;) is fine for a first test. For anything you plan to keep, define the pipeline in a Declarative Automation Bundle so it can be reviewed, versioned, and promoted through environments.&lt;/p&gt;

&lt;p&gt;The config below ingests three core tables and sets a backfill start date. Per the docs, &lt;code&gt;start_datetime&lt;/code&gt; defaults to 365 days before the first sync and only affects that first sync. Later runs resume from the stored cursor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# resources/shopify_pipeline.yml&lt;/span&gt;
&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;shopify_pipeline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shopify_pipeline&lt;/span&gt;
      &lt;span class="na"&gt;catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shopify_bronze&lt;/span&gt;
      &lt;span class="na"&gt;ingestion_definition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# References the Unity Catalog connection; no secrets in this file&lt;/span&gt;
        &lt;span class="na"&gt;connection_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shopify_connection&lt;/span&gt;
        &lt;span class="na"&gt;source_configurations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;api_source_connector_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="c1"&gt;# Only applies to the first sync of each incremental table&lt;/span&gt;
                &lt;span class="na"&gt;start_datetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-01-01T00:00:00+00:00"&lt;/span&gt;
        &lt;span class="na"&gt;objects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;source_schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
              &lt;span class="na"&gt;source_table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders&lt;/span&gt;
              &lt;span class="na"&gt;destination_catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
              &lt;span class="na"&gt;destination_schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shopify_bronze&lt;/span&gt;
              &lt;span class="na"&gt;destination_table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;source_schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
              &lt;span class="na"&gt;source_table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;customers&lt;/span&gt;
              &lt;span class="na"&gt;destination_catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
              &lt;span class="na"&gt;destination_schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shopify_bronze&lt;/span&gt;
              &lt;span class="na"&gt;destination_table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;customers&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;source_schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
              &lt;span class="na"&gt;source_table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;products&lt;/span&gt;
              &lt;span class="na"&gt;destination_catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
              &lt;span class="na"&gt;destination_schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shopify_bronze&lt;/span&gt;
              &lt;span class="na"&gt;destination_table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;products&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a job file to control the refresh schedule. This one runs daily at midnight UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# resources/shopify_job.yml&lt;/span&gt;
&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;shopify_job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shopify_job&lt;/span&gt;
      &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;quartz_cron_expression&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;?"&lt;/span&gt;
        &lt;span class="na"&gt;timezone_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UTC&lt;/span&gt;
      &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shopify_ingestion&lt;/span&gt;
          &lt;span class="na"&gt;pipeline_task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;pipeline_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${resources.pipelines.shopify_pipeline.id}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy with &lt;code&gt;databricks bundle deploy&lt;/code&gt;, starting with a development target. Run the first backfill against a dev or staging catalog before you point anything at production. A year of order history on a large store is a big first sync, and you want to confirm the table shapes before dashboards depend on them.&lt;/p&gt;

&lt;p&gt;A note on the backfill date: if the app doesn't have &lt;code&gt;read_all_orders&lt;/code&gt;, a &lt;code&gt;start_datetime&lt;/code&gt; older than 60 days won't give you older orders. The date and the scope need to agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you build on top of the raw tables?
&lt;/h2&gt;

&lt;p&gt;The connector lands raw data, so the next job is shaping it into something analysts and dashboards can use. The limitations page recommends Spark Declarative Pipelines for downstream transformations, and a materialized view is a simple starting point for a daily sales table.&lt;/p&gt;

&lt;p&gt;The example below is illustrative. Check the &lt;a href="https://docs.databricks.com/gcp/en/ingestion/lakeflow-connect/shopify-reference#supported-tables" rel="noopener noreferrer"&gt;connector reference&lt;/a&gt; for exact column names and types before you use it, since Beta schemas can change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Gold-layer daily sales view (column names are placeholders:&lt;/span&gt;
&lt;span class="c1"&gt;-- verify them against the connector's table reference)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shopify_gold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daily_sales&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&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;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&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;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_price&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&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;gross_sales&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&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;unique_customers&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shopify_bronze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cancelled_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two modeling decisions are worth making early.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gross versus net sales.&lt;/strong&gt; The view above reports gross sales. Net sales needs refunds and discounts subtracted, and those come from their own tables. Settle on one definition and document it, because a finance team and a marketing team pulling "revenue" from different tables is the most common source of dashboard arguments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customer history with SCD Type 2.&lt;/strong&gt; With SCD Type 2 turned on, you keep every version of a customer record instead of only the latest. That lets you answer questions like which segment a customer belonged to when they placed an order. Keep in mind the Beta caveat below: tables with semi-structured columns can't use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the Beta limitations you should plan around?
&lt;/h2&gt;

&lt;p&gt;Most of these are reasonable for a Beta connector, but a few can surprise you in production. The table below collects them from the overview, limitations, and troubleshooting pages.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limitation&lt;/th&gt;
&lt;th&gt;What it means in practice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;39 prebuilt tables only; no custom metafields or reports&lt;/td&gt;
&lt;td&gt;Stores that model key data in metafields need a separate path for those fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;locations&lt;/code&gt;, &lt;code&gt;shop&lt;/code&gt;, &lt;code&gt;collects&lt;/code&gt;, &lt;code&gt;disputes&lt;/code&gt;, &lt;code&gt;countries&lt;/code&gt;, &lt;code&gt;collection_product&lt;/code&gt;, and &lt;code&gt;inventory_level&lt;/code&gt; aren't incremental&lt;/td&gt;
&lt;td&gt;Every run re-ingests these tables in full, so watch runtime and cost on frequent schedules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No SCD Type 2 on tables with VARIANT columns&lt;/td&gt;
&lt;td&gt;Plan history tracking table by table, not as a blanket setting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema evolution handles new and deleted columns, but not type changes&lt;/td&gt;
&lt;td&gt;A type change upstream needs manual handling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Column renames require a full refresh&lt;/td&gt;
&lt;td&gt;Budget time for a re-sync when Shopify renames a field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adding a column later doesn't backfill it&lt;/td&gt;
&lt;td&gt;Run a full refresh on that table if you need its history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Renaming a destination table makes the pipeline API-only&lt;/td&gt;
&lt;td&gt;Decide naming up front if your team relies on the UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No API-based row filtering&lt;/td&gt;
&lt;td&gt;You ingest whole tables and filter downstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alerts on scheduled pipelines fire on the next update, not immediately&lt;/td&gt;
&lt;td&gt;Don't use pipeline alerts as real-time incident monitoring&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two more points deserve attention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protected customer data.&lt;/strong&gt; The setup guide notes that some customer and order fields are protected customer data, and Shopify only returns them from non-development stores once the app meets its protected customer data requirements. If email or address fields come back empty in production but not in development, this is the likely cause. Once those fields do arrive, treat them as PII from day one. Unity Catalog's attribute-based access control can mask columns such as email and phone, and Databricks added &lt;a href="https://docs.databricks.com/aws/en/release-notes/product/2026/september" rel="noopener noreferrer"&gt;metastore-level ABAC policies in Beta&lt;/a&gt; this month, so one masking rule can cover every catalog. Doing this before analysts get access is far easier than retrofitting it for GDPR requests later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limits.&lt;/strong&gt; Shopify rate limits its GraphQL Admin API by query cost, with limits that depend on your plan. According to the troubleshooting guide, the connector waits and retries automatically when it's throttled. That's convenient, but other apps on the same store share that budget. A heavy backfill during peak trading hours can slow down other integrations, so schedule the first sync for a quiet period.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should you still build a custom Shopify pipeline?
&lt;/h2&gt;

&lt;p&gt;The managed connector should be your default for analytics ingestion. It removes a lot of code you'd otherwise have to maintain, and it gets governance right from the start. But it solves one problem well, and some requirements sit outside it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhuzhe0jqvrph32q5dpar.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhuzhe0jqvrph32q5dpar.png" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Managed connector&lt;/th&gt;
&lt;th&gt;Custom pipeline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scheduled analytics on core Shopify objects&lt;/td&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Unnecessary effort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metafield-heavy data models&lt;/td&gt;
&lt;td&gt;Not covered&lt;/td&gt;
&lt;td&gt;Needed for those fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reacting to events within seconds (order confirmations, fraud checks)&lt;/td&gt;
&lt;td&gt;Scheduled syncs&lt;/td&gt;
&lt;td&gt;Webhooks with a queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writing data back to Shopify&lt;/td&gt;
&lt;td&gt;Read-only&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logic across many stores, such as shared catalogs&lt;/td&gt;
&lt;td&gt;One connection per store&lt;/td&gt;
&lt;td&gt;Often simpler to own&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In practice, many teams will end up with a hybrid. The connector handles bulk analytics ingestion, and a small custom service handles metafields or real-time event reactions. If you've built webhook pipelines before, the reliability checks in our &lt;a href="https://dev.to/lucy1/debugging-shopify-webhook-delivery-failures-a-developer-checklist-5f28"&gt;Shopify webhook debugging checklist&lt;/a&gt; still apply to that second half. The only difference is that your service no longer has to double as your analytics pipeline.&lt;/p&gt;

&lt;p&gt;Where the boundary sits depends on the store. It comes down to how much of the data model lives in metafields, how fresh the data needs to be, and how many stores are involved. That trade-off analysis is most of what Lucent works through with commerce clients as a &lt;a href="https://www.lucentinnovation.com/services/data-engineering-with-databricks" rel="noopener noreferrer"&gt;Databricks-focused data engineering company&lt;/a&gt;, and it's worth doing on paper before writing any pipeline code, whoever builds it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should you roll this out safely?
&lt;/h2&gt;

&lt;p&gt;Because the connector is in Beta, treat the rollout as a staged migration rather than a switch.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enable the preview in a non-production workspace first.&lt;/strong&gt; Confirm the tables, scopes, and backfill behavior before production depends on them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request &lt;code&gt;read_all_orders&lt;/code&gt; early&lt;/strong&gt; if historical reporting matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run both pipelines in parallel&lt;/strong&gt; if you're replacing a custom one. Compare row counts and key totals for a week or two before retiring the old pipeline, and keep it available as a rollback until the numbers match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply PII masking before granting analyst access&lt;/strong&gt;, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the pipeline in a bundle&lt;/strong&gt; so every change goes through code review.
The Shopify connector removes the least interesting part of commerce analytics: extraction code that breaks whenever an API changes. What's left is the valuable work of modeling revenue correctly, protecting customer data, and deciding what really needs to be real time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Are you planning to move an existing Shopify pipeline onto the managed connector, or keep a hybrid setup? I'd especially like to hear from anyone whose store relies heavily on metafields, since that's the gap I expect most teams to hit first.&lt;/p&gt;

</description>
      <category>databricks</category>
      <category>shopify</category>
      <category>dataengineering</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Claude Opus 5.5 Is Now on Google Cloud, and I Think It's a Big Deal for Developers</title>
      <dc:creator>Lucy </dc:creator>
      <pubDate>Wed, 23 Sep 2026 06:38:26 +0000</pubDate>
      <link>https://dev.to/lucy1/claude-opus-55-is-now-on-google-cloud-and-i-think-its-a-big-deal-for-developers-3jfg</link>
      <guid>https://dev.to/lucy1/claude-opus-55-is-now-on-google-cloud-and-i-think-its-a-big-deal-for-developers-3jfg</guid>
      <description>&lt;p&gt;Anthropic released Claude Opus 5.5 this week, and it's &lt;a href="https://www.anthropic.com/claude-opus-5-5" rel="noopener noreferrer"&gt;available on Amazon Web Services, Google Cloud, and Microsoft Azure&lt;/a&gt; from day one. Google has also &lt;a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/partner-models/claude/opus-5-5" rel="noopener noreferrer"&gt;published its own model page&lt;/a&gt; for it. I spent some time going through the announcement and the Google Cloud docs, and this release stands out to me for one simple reason: it's more capable &lt;em&gt;and&lt;/em&gt; cheaper at the same time. Here's what caught my attention and what you should know before you try it.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's a big jump in capability
&lt;/h2&gt;

&lt;p&gt;Opus 5.5 is the first model in the new Claude 5.5 family. Anthropic says it performs at the level of Claude Fable 5.1 on most work and costs 40% less to run than Opus 5. That's notable because Fable sits in Anthropic's higher, more expensive tier.&lt;/p&gt;

&lt;p&gt;The benchmark numbers back this up. On Terminal-Bench 4.0, which tests multi-step tasks in a command line, Opus 5.5 scores 66.4%, compared with 55.8% for Fable 5.1 and 52.3% for Opus 5. Anthropic does add a fair caveat: at this level of capability, benchmark margins have become a less reliable guide to real-world differences.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pricing is what excites me most
&lt;/h2&gt;

&lt;p&gt;Input and output tokens now cost $4 and $20 per million, 20% less than Opus 5. Cache reads, which make up most of the cost in agentic and coding work, are $0.20 per million tokens, 60% cheaper than before. It also generates output more than 30% faster than Opus 5.&lt;/p&gt;

&lt;p&gt;The savings add up because it also uses fewer tokens per task. Lower price per token plus fewer tokens nets out to a 40% cost drop.&lt;/p&gt;

&lt;p&gt;One note: these are Anthropic's list prices. Google Cloud bills Claude usage through its own generative AI pricing page, so check the rates for your region before estimating costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's built for long engineering work
&lt;/h2&gt;

&lt;p&gt;Anthropic says Opus 5.5 is especially good at long, sprawling jobs like codebase-wide migrations and audits. A few examples from the announcement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One early tester audited and fixed a 200,000-line codebase in under three hours, where Opus 5 took more than 20 hours and used 2.5x as many tokens.&lt;/li&gt;
&lt;li&gt;In an internal test, Opus 5.5 and Fable 5.1 both translated HAProxy from C to Rust. Opus 5.5 finished in 9.5 hours versus 12 for Fable 5.1, at 51% lower cost.&lt;/li&gt;
&lt;li&gt;When asked to cut load times across every page of a web app, Opus 5.5 succeeded 39 out of 40 times.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Its writing is clearer
&lt;/h2&gt;

&lt;p&gt;This is a quieter change, but I think developers will appreciate it. Anthropic says Opus 5.5 puts the most important information first, is less likely to use jargon, and follows the writing rules you give it. If you've ever tried to review a long, rambling agent summary at the end of the day, you'll know why this matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'd use it through Google Cloud
&lt;/h2&gt;

&lt;p&gt;If your team already runs on Google Cloud, this is the easiest way in. You keep your existing IAM, billing, and monitoring, and you get data residency options: multi-region endpoints that route dynamically within the US or EU, or regional endpoints that guarantee routing through a specific region.&lt;/p&gt;

&lt;p&gt;It's also useful for planning that Google lists the model's retirement date as no sooner than September 22, 2027, so you can build on it knowing it won't disappear anytime soon.&lt;/p&gt;

&lt;p&gt;Getting started looks straightforward. First, install the SDK with Google Cloud support:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="s2"&gt;"anthropic[vertex]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then enable the model in Model Garden, authenticate with Application Default Credentials (&lt;code&gt;gcloud auth application-default login&lt;/code&gt;), and make a call:&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;from&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AnthropicVertex&lt;/span&gt;

&lt;span class="c1"&gt;# Keep the project ID in an environment variable, not in code
&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AnthropicVertex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;project_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;GCP_PROJECT_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;region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# multi-region: "us" or "eu"
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-opus-5-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&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;role&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;user&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;content&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;Hey Claude!&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you call the REST API directly instead of using the SDK, there are two differences from Anthropic's own API: the model is specified in the endpoint URL rather than the request body, and &lt;code&gt;anthropic_version&lt;/code&gt; goes in the body with the value &lt;code&gt;vertex-2023-10-16&lt;/code&gt;. Anthropic's &lt;a href="https://platform.claude.com/docs/en/build-with-claude/claude-on-vertex-ai" rel="noopener noreferrer"&gt;Claude on Google Cloud guide&lt;/a&gt; walks through the full setup.&lt;/p&gt;

&lt;p&gt;Swapping the model ID is the easy part. The bigger work is usually fitting a new model into the systems around it: prompts, evals, cost monitoring, and fallbacks. That's the area our team focuses on in &lt;a href="https://www.lucentinnovation.com/services/llm-development" rel="noopener noreferrer"&gt;LLM development and integration&lt;/a&gt;, and it's where I'd spend most of the testing time before any switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to check before switching
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thinking is always on.&lt;/strong&gt; Opus 5.5 &lt;a href="https://platform.claude.com/docs/en/models/opus-5-5/whats-new-opus-5-5#thinking-cant-be-disabled" rel="noopener noreferrer"&gt;can no longer be used with thinking switched off&lt;/a&gt;. If your integration disables it today, update that first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some requests are routed to other models.&lt;/strong&gt; Opus 5.5 ships with safeguards similar to Fable 5.1's for cybersecurity, biology, and distillation, and these fall back to another model transparently. Routine bug finding and fixing still works normally, but most other cybersecurity tasks are re-routed to Opus 4.8.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test in staging first.&lt;/strong&gt; Whatever model you're moving from, run your own prompts and evals in a staging environment before switching production traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final Verdict
&lt;/h2&gt;

&lt;p&gt;Anthropic says Claude Sonnet 5.5 and Claude Haiku 5.5 will follow in the coming weeks, with many of the same improvements to performance, efficiency, and safety. If those smaller models get similar efficiency gains, it'll be a very good few months for anyone building with AI.&lt;/p&gt;

&lt;p&gt;I'm planning to try Opus 5.5 on our own workloads soon, and I'll share what I find in a follow-up post. Have you tried it yet? Let me know in the comments what you're building with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.anthropic.com/claude-opus-5-5" rel="noopener noreferrer"&gt;Introducing Claude Opus 5.5&lt;/a&gt; (Anthropic)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/partner-models/claude/opus-5-5" rel="noopener noreferrer"&gt;Claude Opus 5.5 on Google Cloud&lt;/a&gt; (Google Cloud docs)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.claude.com/docs/en/build-with-claude/claude-on-vertex-ai" rel="noopener noreferrer"&gt;Claude on Google Cloud setup guide&lt;/a&gt; (Anthropic docs)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://anthropic.com/claude-opus-5-5-system-card" rel="noopener noreferrer"&gt;Claude Opus 5.5 System Card&lt;/a&gt; (Anthropic)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/googleai/claude-opus-55-is-now-available-on-google-cloud-2oh"&gt;Claude Opus 5.5 is now available on Google Cloud&lt;/a&gt; (Google AI on dev.to)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>googlecloud</category>
      <category>claude</category>
      <category>llm</category>
    </item>
    <item>
      <title>Running AWS-Backed Migrations Into Shopify Plus: A Technical Overview</title>
      <dc:creator>Lucy </dc:creator>
      <pubDate>Wed, 16 Sep 2026 09:35:55 +0000</pubDate>
      <link>https://dev.to/lucy1/running-aws-backed-migrations-into-shopify-plus-a-technical-overview-a3n</link>
      <guid>https://dev.to/lucy1/running-aws-backed-migrations-into-shopify-plus-a-technical-overview-a3n</guid>
      <description>&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Once a catalog crosses roughly 50,000 SKUs, or the source system is a custom ERP rather than a standard cart, CSV imports and off-the-shelf migration apps stop being a data problem and start being a rate-limit and reconciliation problem. The fix isn't a bigger export file. It's treating the migration as a real ETL pipeline: extract with change data capture so the source stays live, stage and validate in S3, and load through Shopify's asynchronous Bulk Operations API instead of looping calls against the Admin API. This post walks through that architecture and where it breaks down at different scales.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the standard Shopify Plus migration path stops working at scale
&lt;/h2&gt;

&lt;p&gt;Most Shopify Plus migrations start the same way: export CSVs from the old platform, clean them in a spreadsheet, and either run Shopify's native import or a migration app like Matrixify. That works fine for a few thousand SKUs with simple variant structures.&lt;/p&gt;

&lt;p&gt;It stops working for two structural reasons, not just volume.&lt;/p&gt;

&lt;p&gt;First, the Shopify Admin API is rate-limited by design, and the limit is enforced per shop regardless of which tool is calling it. Per &lt;a href="https://help.shopify.com/en/api/graphql-admin-api/call-limit" rel="noopener noreferrer"&gt;Shopify's current API rate limit documentation&lt;/a&gt;, the GraphQL Admin API uses a calculated query-cost model: a Shopify Plus store restores 1,000 cost points per second against its bucket, versus 100 for a standard plan, and the REST Admin API caps Plus stores at 20 requests per second versus 2 for a standard plan. A migration app doesn't get a special exemption from this. If your product-plus-metafield-plus-variant graph requires four or five API calls per SKU, a six-figure catalog will take the better part of a week to load serially, and that's before retries.&lt;/p&gt;

&lt;p&gt;Second, CSV round-trips lose structure. Metafields, metaobjects, B2B catalog assignments, and multi-location inventory don't map cleanly onto flat rows, so teams end up hand-patching data after import, which is exactly where SKUs go missing or price rules apply to the wrong customer group.&lt;/p&gt;

&lt;p&gt;The practical implication: once you're migrating a catalog that's large, deeply structured, or sourced from a system with no clean export tool (a custom ERP, a legacy PIM, a headless commerce backend), the migration needs an actual extraction and transformation layer sitting in front of Shopify, not a spreadsheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an AWS-backed migration pipeline actually looks like
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx0y14izgtktzgeuzmp7o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx0y14izgtktzgeuzmp7o.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
The shape of this is closer to a data engineering pipeline than a website migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Legacy DB / ERP / PIM]
        |
        v
  AWS DMS (full load + CDC)  ----&amp;gt;  Amazon S3 (raw zone)
        |
        v
  AWS Glue / Lambda (transform, dedupe, map schema)
        |
        v
  Amazon S3 (validated zone) ----&amp;gt;  reconciliation checks
        |
        v
  JSONL build (products, variants, metafields, customers, orders)
        |
        v
  Shopify staged upload  ----&amp;gt;  bulkOperationRunMutation
        |
        v
  Shopify Plus store (staging, then production)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each arrow in that diagram is a place teams lose data if it's skipped, which is really the argument for building it this way rather than scripting a one-off exporter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extraction (AWS DMS).&lt;/strong&gt; Rather than taking the source database offline for a bulk export, &lt;a href="https://aws.amazon.com/documentation-overview/dms" rel="noopener noreferrer"&gt;AWS DMS&lt;/a&gt; performs a full load of existing records and then switches to change data capture (CDC), continuously replicating inserts, updates, and deletes from the source until you're ready to cut over: the source database stays operational throughout, and the target stays synchronized for as long as you need before you switch over. That matters specifically for commerce migrations because orders and inventory keep changing during the weeks a large migration takes to build and test, so you can't just freeze the source system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Staging and transformation (S3, Glue, Lambda).&lt;/strong&gt; Raw extracted data lands in an S3 "raw zone" untouched, then a transform layer (&lt;a href="https://aws.amazon.com/glue/" rel="noopener noreferrer"&gt;Glue&lt;/a&gt;, a serverless data integration service with no infrastructure to provision, for anything approaching bulk ETL, paired with Lambda for lighter per-record logic) reshapes source rows into Shopify's object model: variant option combinations, metafield namespaces, customer-to-company relationships for B2B. &lt;a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/patterns/orchestrate-an-etl-pipeline-with-validation-transformation-and-partitioning-using-aws-step-functions.html" rel="noopener noreferrer"&gt;AWS's own reference pattern&lt;/a&gt; for this kind of pipeline explicitly separates validation, transformation, and partitioning into distinct orchestrated steps with automated retry and error handling. It's worth copying directly, because "transform" and "validate" being separate steps is what lets you catch bad data before it reaches Shopify rather than after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orchestration (Step Functions or EventBridge).&lt;/strong&gt; A migration isn't one job, it's a dependency graph: customers before orders, products before variants, collections before the products that reference them. Step Functions state machines make that sequencing and its error handling visible and restartable at the step that failed, rather than needing to rerun an entire script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading into Shopify: bulk operations, not a request loop
&lt;/h2&gt;

&lt;p&gt;This is the step where AWS-side engineering meets Shopify-side engineering, and it's the one most agencies underbuild.&lt;/p&gt;

&lt;p&gt;Shopify's &lt;a href="https://shopify.dev/docs/api/usage/bulk-operations/imports" rel="noopener noreferrer"&gt;Bulk Operations API&lt;/a&gt; is built for exactly this case: instead of one GraphQL mutation per record, you upload a single JSONL file where each line holds one mutation's variables, reserve the upload target with &lt;code&gt;stagedUploadsCreate&lt;/code&gt;, then run &lt;code&gt;bulkOperationRunMutation&lt;/code&gt; against the uploaded file, and Shopify executes every line asynchronously in the background. This entirely bypasses the per-request rate limit that would otherwise throttle a six-figure catalog load.&lt;/p&gt;

&lt;p&gt;A minimal shape of the mutation that kicks off the import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;mutation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;bulkOperationRunMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mutation call($input: ProductInput!) { productCreate(input: $input) { product { id } userErrors { field message } } }"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;stagedUploadPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tmp/4/bulk-op-inputs/product-import.jsonl"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;bulkOperation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;userErrors&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two constraints worth designing around from day one: per &lt;a href="https://shopify.dev/docs/api/admin-graphql/latest/mutations/bulkoperationrunmutation" rel="noopener noreferrer"&gt;Shopify's own API reference&lt;/a&gt;, only one bulk mutation operation can run at a time per shop (a bulk query can run alongside it, but not another bulk mutation), and result files are only available for seven days after the operation completes, so your validation step needs to run promptly rather than "whenever someone gets to it." Neither of these is a big deal if you planned for a sequential pipeline; both cause real problems if your Step Functions state machine assumed it could parallelize product and variant loads freely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The validation layer that actually prevents launch-day surprises
&lt;/h2&gt;

&lt;p&gt;The failure mode nobody plans for isn't "the migration failed." It's "the migration succeeded and the numbers are quietly wrong." A price rule applies to the wrong customer group. Three thousand SKUs land without their images because the CDN path changed casing. Order history imports but loses the tax line because the source system stored tax differently than Shopify's tax model expects.&lt;/p&gt;

&lt;p&gt;This is where having the migration built on a real data engineering stack pays off, because reconciliation is a data problem, not a migration-app checkbox. A working validation pass typically checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Row-count parity&lt;/strong&gt;: does the object count in Shopify's bulk operation result match the row count in the source extract, per object type&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Referential integrity&lt;/strong&gt;: does every variant resolve to a real product, every order to a real customer, every metafield to a defined definition&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sampled field-level diffing&lt;/strong&gt;: pull a random sample of records post-load and diff key fields (price, inventory quantity, SKU) against the source of truth, not just spot-check the ones that are easy to eyeball&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business-rule checks specific to the store&lt;/strong&gt;: B2B price list assignment, subscription contract continuity, multi-location inventory splits
None of this is exotic. It's the same reconciliation discipline used in a data warehouse load, applied to a commerce migration instead. The teams that skip it are the ones that find the discrepancy from a customer support ticket three weeks after go-live.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cutover: the part where architecture becomes a business decision
&lt;/h2&gt;

&lt;p&gt;Because DMS is running CDC rather than a one-time dump, the technical cutover moment is genuinely small, often measured in minutes rather than hours, since the target has been kept in sync continuously and the source can remain operational until you choose the switchover moment. But "small technical window" and "safe business cutover" aren't the same thing.&lt;/p&gt;

&lt;p&gt;A cutover plan that holds up under pressure usually includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a defined freeze window for order-affecting writes on the source system, even if it's brief&lt;/li&gt;
&lt;li&gt;a final CDC catch-up and reconciliation pass immediately before DNS/storefront cutover&lt;/li&gt;
&lt;li&gt;a rollback path that doesn't require re-running the entire migration; usually this means keeping the old platform live and read-only for a defined window, not decommissioning it on day one&lt;/li&gt;
&lt;li&gt;a named owner for the go/no-go decision, separate from the engineer running the migration
The architecture buys you a short technical window. It doesn't remove the need for a plan if something is wrong when that window opens.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When you actually need this versus when a migration app is enough
&lt;/h2&gt;

&lt;p&gt;Building DMS, S3, Glue, and Step Functions into a migration is real engineering overhead. It's not the default answer for every store moving to Shopify Plus.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Migration app / CSV is probably fine&lt;/th&gt;
&lt;th&gt;AWS-backed pipeline is worth it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Catalog size&lt;/td&gt;
&lt;td&gt;Under ~20,000 SKUs, simple variants&lt;/td&gt;
&lt;td&gt;50,000+ SKUs, or complex variant/metafield structure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source system&lt;/td&gt;
&lt;td&gt;Standard cart (WooCommerce, BigCommerce, Magento with clean export)&lt;/td&gt;
&lt;td&gt;Custom ERP, in-house PIM, or a system with no reliable bulk export&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order/inventory activity during migration&lt;/td&gt;
&lt;td&gt;Can tolerate a short freeze window&lt;/td&gt;
&lt;td&gt;Needs to stay live and transacting throughout the build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data model complexity&lt;/td&gt;
&lt;td&gt;Flat product catalog&lt;/td&gt;
&lt;td&gt;B2B pricing, multi-location inventory, subscriptions, metaobjects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Historical data&lt;/td&gt;
&lt;td&gt;Products and current inventory only&lt;/td&gt;
&lt;td&gt;Full order history, customer accounts, and audit trail required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're in the left column, a well-run migration app project is faster and cheaper than standing up a DMS pipeline for its own sake. The pipeline earns its cost when the source system itself is the obstacle, not just the volume of rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to test before you commit to an architecture
&lt;/h2&gt;

&lt;p&gt;Before scoping a migration this way, it's worth running a small proof of concept rather than committing the full build:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Extract a representative slice (a few hundred SKUs with your messiest variant structure, not your cleanest ones) and run it end to end through the pipeline&lt;/li&gt;
&lt;li&gt;Confirm the Bulk Operations import actually preserves the metafield and variant relationships you need. This is the step that most commonly reveals schema-mapping gaps&lt;/li&gt;
&lt;li&gt;Time a DMS CDC catch-up under realistic write volume, not idle volume, to get a real cutover-window estimate&lt;/li&gt;
&lt;li&gt;Decide your rollback trigger conditions in writing before go-live, not during an incident
If that proof of concept surfaces more schema-mapping problems than rate-limit problems, that's a useful signal that the bottleneck was never really the API. It was the data model, and no amount of AWS infrastructure fixes a data model problem on its own. That's usually the point where it's worth bringing in a team that's done the Databricks-and-data-engineering side of this as often as the Shopify side, since &lt;a href="https://www.lucentinnovation.com/services/shopify-migration" rel="noopener noreferrer"&gt;Lucent Innovation's Shopify migration team&lt;/a&gt; works both ends of that pipeline rather than treating the AWS layer as an afterthought.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;CSV imports and migration apps are the right tool until the source system or the catalog size makes them the bottleneck. Past that point, the pipeline that actually holds up looks like a data engineering job: CDC-based extraction so the source stays live, S3 as a staging and audit layer, Glue or Lambda for transformation, Step Functions for sequencing, and Shopify's Bulk Operations API, not a request loop, for the load itself. The architecture doesn't remove the need for reconciliation or a real cutover plan. It just gives you a pipeline where those things are possible to do properly instead of bolted on after the fact.&lt;/p&gt;

&lt;p&gt;What's the largest catalog or messiest source system you've had to move onto Shopify Plus, and did the migration tooling hold up?&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>aws</category>
      <category>dataengineering</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>How to Fix Shopify POS and EDC Terminal Reconciliation Mismatches</title>
      <dc:creator>Lucy </dc:creator>
      <pubDate>Tue, 08 Sep 2026 11:22:35 +0000</pubDate>
      <link>https://dev.to/lucy1/how-to-fix-shopify-pos-and-edc-terminal-reconciliation-mismatches-a56</link>
      <guid>https://dev.to/lucy1/how-to-fix-shopify-pos-and-edc-terminal-reconciliation-mismatches-a56</guid>
      <description>&lt;p&gt;If your cashiers ring up the sale in Shopify POS and then key the same total into a separate EDC card terminal by hand, you already have two systems of record for the same transaction. This post walks through why that gap opens up, which integration model actually closes it, and what the API calls, webhook handling, and reconciliation logic look like when you build the bridge yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Shopify POS and EDC Terminals Fall Out of Sync
&lt;/h2&gt;

&lt;p&gt;Most retail counters running Shopify POS alongside a third-party EDC (Electronic Data Capture) terminal are running two independent systems that were never designed to talk to each other. The cashier totals the cart in Shopify POS, then re-enters that same amount on the terminal keypad, selects a payment mode, and waits for the customer to tap or insert a card.&lt;/p&gt;

&lt;p&gt;At that point, two separate ledgers exist. Shopify has an order that is either unpaid or manually marked paid. The terminal has its own batch report with its own transaction ID, sitting in a completely different system. Nothing links the two unless a human writes the terminal's transaction ID onto a receipt or into an order note, and in practice, a lot of stores skip that step entirely because there's no fast way to do it at the counter under a queue of customers.&lt;/p&gt;

&lt;p&gt;This is a known pattern in payments infrastructure, not a Shopify-specific quirk. Payment providers generally describe two ends of a spectrum: standalone terminals that process a card with zero coding effort but leave order and payment data unlinked, and integrated (or cloud) terminals that require an API integration but keep the two systems aligned automatically, as &lt;a href="https://www.airwallex.com/docs/payments/integration-options/pos-payments/integrated-terminal-sdk" rel="noopener noreferrer"&gt;Airwallex's integrated terminal documentation&lt;/a&gt; lays out. Most EDC-based retail counters are running the standalone version today, and the reconciliation cost of that choice tends to stay invisible until someone adds up the hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Integration Models, and Why the Model You Pick Decides Your Reconciliation Story
&lt;/h2&gt;

&lt;p&gt;Before writing a line of integration code, it's worth naming which model you're actually building toward, because each one has a different blast radius for PCI scope and engineering effort.&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;How payment data flows&lt;/th&gt;
&lt;th&gt;Reconciliation&lt;/th&gt;
&lt;th&gt;Typical fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standalone terminal&lt;/td&gt;
&lt;td&gt;Cashier re-keys the amount; terminal and POS never talk&lt;/td&gt;
&lt;td&gt;Fully manual, batch report vs. order list&lt;/td&gt;
&lt;td&gt;Single-location, low volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semi-integrated terminal&lt;/td&gt;
&lt;td&gt;POS sends the amount and receives a result; card data stays on the terminal, out of the POS app&lt;/td&gt;
&lt;td&gt;Automatic per-transaction match&lt;/td&gt;
&lt;td&gt;Multi-location retail, the common target for EDC bridges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fully integrated terminal&lt;/td&gt;
&lt;td&gt;POS or its backend handles more of the payment flow directly with the processor&lt;/td&gt;
&lt;td&gt;Automatic, tightest coupling&lt;/td&gt;
&lt;td&gt;Vertical SaaS or processor-owned hardware&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Semi-integration is the model most Shopify POS and EDC bridges land on, and for good reason. As &lt;a href="https://www.datatel-systems.com/article/integrated-vs-semi-integrated-pos-payment-solutions-explained/" rel="noopener noreferrer"&gt;Datatel Systems explains&lt;/a&gt;, a semi-integrated setup treats the terminal as the thing that actually touches card data, while the POS only ever sees an amount request and a result, which keeps sensitive data away from the POS application entirely. This is also the model that maps cleanly onto Shopify's own extension points, since the integration layer just needs to relay a request and listen for a confirmation, not process a card itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Closing the Gap Looks Like at the API Level
&lt;/h2&gt;

&lt;p&gt;Once the terminal confirms a payment, the job is to get that confirmation back into Shopify without a human re-typing anything. Shopify's Admin GraphQL API gives you two mutations built for exactly this kind of external payment record.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;orderMarkAsPaid&lt;/code&gt; is built for orders where the outstanding balance needs to be cleared based on a payment that happened outside Shopify's standard checkout, which is precisely the situation once an EDC terminal confirms a card charge. According to &lt;a href="https://shopify.dev/docs/api/admin-graphql/latest/mutations/ordermarkaspaid" rel="noopener noreferrer"&gt;Shopify's own mutation reference&lt;/a&gt;, the mutation either creates a new sale transaction for the full outstanding amount or captures an existing authorized transaction, and it updates the order's financial status once it succeeds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;mutation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MarkOrderPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;OrderMarkAsPaidInput&lt;/span&gt;&lt;span class="p"&gt;!)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;orderMarkAsPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;displayFinancialStatus&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;userErrors&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gid://shopify/Order/820982911946154508"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need to record the terminal's own transaction ID, timestamp, or payment method label alongside the payment rather than just flipping a status, &lt;a href="https://shopify.dev/docs/api/admin-graphql/latest/mutations/ordercreatemanualpayment" rel="noopener noreferrer"&gt;&lt;code&gt;orderCreateManualPayment&lt;/code&gt;&lt;/a&gt; is the better fit. It lets you pass an amount, a method name, and a &lt;code&gt;processedAt&lt;/code&gt; timestamp, which matters if your webhook arrives a few seconds after the actual charge and you want the order's payment record to reflect when the customer's card was actually charged, not when your server got around to processing the event.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Reference Architecture for the Bridge
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwjvq6c3q0vzsfafe4gd7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwjvq6c3q0vzsfafe4gd7.png" alt=" " width="800" height="298"&gt;&lt;/a&gt;&lt;br&gt;
The actual bridge is a small, boring service that sits between the EDC provider's webhook or SDK callback and Shopify's Admin API. A minimal version looks like this in Node.js:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&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="c1"&gt;// Prevent double-processing if the provider retries the same event&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;processedEvents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/edc-terminal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;transactionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;processedEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transactionId&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;already processed&lt;/span&gt;&lt;span class="dl"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Log declines and cancellations for the reconciliation log,&lt;/span&gt;
    &lt;span class="c1"&gt;// but don't touch the Shopify order.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;logged, no action needed&lt;/span&gt;&lt;span class="dl"&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markShopifyOrderPaid&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;transactionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;processedEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transactionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reconciled&lt;/span&gt;&lt;span class="dl"&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Don't swallow the error. A missed webhook here is exactly&lt;/span&gt;
    &lt;span class="c1"&gt;// the gap this whole integration exists to close.&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reconciliation failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;transactionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retry me&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things in that snippet matter more than they look:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency isn't optional.&lt;/strong&gt; EDC providers commonly retry webhook delivery if they don't get a fast 200 response, and calling &lt;code&gt;orderMarkAsPaid&lt;/code&gt; twice against the same order will surface a user error since the mutation checks that the order isn't already &lt;code&gt;PAID&lt;/code&gt;, per the &lt;a href="https://shopify.dev/docs/api/admin-graphql/latest/objects/Order" rel="noopener noreferrer"&gt;Order object's &lt;code&gt;canMarkAsPaid&lt;/code&gt; field&lt;/a&gt;. A transaction-ID set (or a database table in production) avoids duplicate processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Declines and cancellations still need to be logged&lt;/strong&gt;, even though they shouldn't touch the Shopify order. This is what makes the eventual reconciliation log complete instead of only showing successful payments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failures should return a 5xx, not swallow the error.&lt;/strong&gt; If your bridge silently eats a failed API call, you've recreated the exact manual-matching problem this integration was built to remove, just one layer further from the counter where it's harder to spot.
## Where PCI Scope Actually Sits in This&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A fair question from anyone reviewing this kind of integration is whether it touches cardholder data. It shouldn't, and if it does, the design needs to change before it ships.&lt;/p&gt;

&lt;p&gt;In a semi-integrated model, the terminal captures and encrypts the card data itself, and the POS-side integration only ever sees an amount, a status, and a transaction reference. This is the architectural basis for PCI DSS scope reduction: as the &lt;a href="https://www.pcisecuritystandards.org/faq/articles/Frequently_Asked_Question/How-should-payment-terminals-be-considered-during-a-PCI-DSS-assessment/" rel="noopener noreferrer"&gt;PCI Security Standards Council notes in its own FAQ&lt;/a&gt;, devices that support Secure Reading and Exchange of Data can facilitate scope reduction for merchants when used as part of a validated point-to-point encryption solution, though a terminal being PTS-approved doesn't automatically guarantee that reduction on its own. The details vary by provider and by how the merchant's environment is segmented, which is why &lt;a href="https://docs.adyen.com/point-of-sale/reconciling-totals/reconcile-a-balance-mismatch" rel="noopener noreferrer"&gt;Adyen documents the reconciliation and payment-intent flow separately from card capture&lt;/a&gt; in its own point-of-sale API reference. The practical takeaway for anyone building this bridge: keep raw card data entirely off your server, treat the webhook payload as amount-and-status only, and confirm with the EDC provider exactly what their webhook or callback does and doesn't include before you write a single handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes When Teams Build This Themselves
&lt;/h2&gt;

&lt;p&gt;A few patterns show up repeatedly in bridges like this, and most of them trace back to treating the integration as a one-off script instead of infrastructure.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No retry or dead-letter handling.&lt;/strong&gt; A webhook that fails once and is never retried recreates the manual-matching problem for exactly the transactions that mattered enough to fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming one terminal provider forever.&lt;/strong&gt; Multi-location chains often run different EDC hardware in different regions. Building the bridge around a single provider's payload shape instead of a normalized internal event format makes adding a second provider a rewrite instead of a config change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipping the reconciliation log.&lt;/strong&gt; The API calls above solve the order-status half of the problem. The other half is a queryable log of every terminal event, successful or not, with order, store, provider, amount, status, and timestamp, so a finance team can filter by date range or location instead of stitching together exports by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No plan for refunds.&lt;/strong&gt; Getting a payment confirmation back into Shopify automatically is the first milestone, not the whole project. Refund flows initiated from the terminal side need the same kind of bridge in reverse, and teams that skip this end up back to manual reconciliation the moment a customer asks for money back.
## What "Good" Reconciliation Looks Like Once It's Live&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once the bridge is running, the operational difference is straightforward to describe: every terminal transaction, across every store and every provider, lands in one place with the same fields, instead of living in separate batch reports that someone has to manually cross-reference. A useful minimum schema for that log looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Order ID&lt;/td&gt;
&lt;td&gt;Links the terminal event back to the exact Shopify order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store / location&lt;/td&gt;
&lt;td&gt;Needed once you're past a single counter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider&lt;/td&gt;
&lt;td&gt;Matters the moment you run more than one EDC vendor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amount&lt;/td&gt;
&lt;td&gt;The number that actually needs to match, transaction by transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status&lt;/td&gt;
&lt;td&gt;approved, declined, cancelled, or error, not just "paid"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timestamp&lt;/td&gt;
&lt;td&gt;Traces disputes back to a specific charge, not just a day&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We wrote up how this played out for a real multi-location retailer, including what the manual process was actually costing them before the fix, in &lt;a href="https://www.lucentinnovation.com/resources/technology-posts/shopify-pos-edc-terminal-integration" rel="noopener noreferrer"&gt;our case study on closing the Shopify POS and EDC terminal reconciliation gap&lt;/a&gt;. If you're evaluating whether this is worth building in-house versus bringing in a team that's done it before, that write-up covers the actual before-and-after numbers. For teams earlier in the process, still deciding on the underlying Shopify build before tackling hardware integrations, a broader look at what a &lt;a href="https://www.lucentinnovation.com/services/shopify-development-agency" rel="noopener noreferrer"&gt;custom Shopify development engagement&lt;/a&gt; typically covers is a reasonable next stop.&lt;/p&gt;

&lt;p&gt;Reconciliation gaps like this rarely get fixed because nobody logs a single incident bad enough to justify the project. It's a few minutes here, a mismatched batch there, a slower month-end close, spread thin enough that it never tops anyone's priority list on its own. But once you can name the exact API calls and webhook shape that close it, it stops being an ops tax and becomes a two-to-four week engineering project.&lt;/p&gt;

&lt;p&gt;Have you built (or inherited) a POS-to-payment-terminal bridge like this? What broke first once it hit production, webhook retries, idempotency, or something else nobody planned for? Curious to hear how other teams handled it.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>pos</category>
      <category>stripe</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Shopify GraphQL Rate Limits: A Query-Cost Optimization Guide</title>
      <dc:creator>Lucy </dc:creator>
      <pubDate>Wed, 26 Aug 2026 13:06:24 +0000</pubDate>
      <link>https://dev.to/lucy1/shopify-graphql-rate-limits-a-query-cost-optimization-guide-1io1</link>
      <guid>https://dev.to/lucy1/shopify-graphql-rate-limits-a-query-cost-optimization-guide-1io1</guid>
      <description>&lt;p&gt;Shopify's GraphQL Admin API does not limit you by how many requests you send. It limits you by how much each request costs. A query that asks for ten cheap fields can run far more often than one that asks for three expensive ones, and once you understand the cost formula, most 429 errors stop being a mystery and start being a math problem.&lt;/p&gt;

&lt;p&gt;This guide breaks down how the cost model works. You'll learn how to read the numbers Shopify sends back with every response, and which query patterns quietly burn through your rate limit budget. It also covers when to stop optimizing a query and switch to a bulk operation instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Shopify moved away from counting requests
&lt;/h2&gt;

&lt;p&gt;The REST Admin API charged a flat fee per call. A request for one product field cost the same as a request for fifty. That model was simple, but it rewarded sloppy queries and punished apps that made frequent small calls for good reasons, like polling a webhook queue.&lt;/p&gt;

&lt;p&gt;Shopify has been unwinding REST in favor of GraphQL for a few years now. The REST Admin API became a legacy API on October 1, 2024, and starting April 1, 2025, every new public app submitted to the Shopify App Store has to run on GraphQL only, according to &lt;a href="https://shopify.dev/changelog/starting-april-2025-new-public-apps-submitted-to-shopify-app-store-must-use-graphql" rel="noopener noreferrer"&gt;Shopify's developer changelog&lt;/a&gt;. Existing apps can keep calling REST for now, but new builds do not have that option. If you are scoping a new Shopify app in 2026, the query cost model is not an edge case to learn later. It is the rate limit you will live under from day one.&lt;/p&gt;

&lt;p&gt;GraphQL made this different approach possible in the first place. The server can look at a query's shape before it runs and estimate the work involved, something a REST endpoint can't do. Shopify's engineering team wrote about the reasoning behind this shift in a &lt;a href="https://shopify.engineering/rate-limiting-graphql-apis-calculating-query-complexity" rel="noopener noreferrer"&gt;detailed post on calculating GraphQL query complexity&lt;/a&gt;. It's worth reading for the full backstory.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the cost model works
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh7ayxkq2xexiugup89o2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh7ayxkq2xexiugup89o2.png" alt=" " width="800" height="480"&gt;&lt;/a&gt;&lt;br&gt;
Shopify's GraphQL Admin API uses what it calls a calculated query cost, enforced through a &lt;a href="https://en.wikipedia.org/wiki/Leaky_bucket" rel="noopener noreferrer"&gt;leaky bucket&lt;/a&gt;. Picture a bucket that fills up as you make requests and drains at a steady rate every second. Send too many expensive queries too fast and the bucket overflows, and you get throttled until it has room again.&lt;/p&gt;

&lt;p&gt;Every field in the schema carries a cost. According to &lt;a href="https://shopify.dev/docs/api/usage/limits" rel="noopener noreferrer"&gt;Shopify's official API limits documentation&lt;/a&gt;, the defaults break down like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field type&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scalar (string, int, boolean, etc.)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enum&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interface&lt;/td&gt;
&lt;td&gt;Maximum of possible selections&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Union&lt;/td&gt;
&lt;td&gt;Maximum of possible selections&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection (edges/nodes list)&lt;/td&gt;
&lt;td&gt;Sized by &lt;code&gt;first&lt;/code&gt; or &lt;code&gt;last&lt;/code&gt; argument&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mutation&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jump out from that table. First, plain scalar fields are free, so asking for a product's title, handle, and status does not move the needle. Second, connections scale with how many items you request, and mutations carry a flat cost of 10 points regardless of how simple the write is, because a mutation triggers side effects like database writes and webhook dispatches that a read never does.&lt;/p&gt;

&lt;p&gt;Your rate limit itself depends on your store's plan. Shopify's limits page lists these figures for the &lt;a href="https://shopify.dev/docs/api/admin-graphql" rel="noopener noreferrer"&gt;GraphQL Admin API&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan tier&lt;/th&gt;
&lt;th&gt;Points restored per second&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standard (Basic and Shopify plans)&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Advanced Shopify&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopify Plus&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopify for enterprise (Commerce Components)&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No matter which tier you are on, a single query cannot exceed 1,000 points. That cap is enforced before the query runs, based on its requested cost, so a query that would cost 1,200 points gets rejected outright rather than partially executed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reading the cost data Shopify sends back
&lt;/h2&gt;

&lt;p&gt;Every GraphQL Admin API response includes an &lt;code&gt;extensions&lt;/code&gt; block. It tells you exactly what happened with your query's cost. Most developers skip this part of the response, right up until they're debugging a production incident at 2 a.m.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"extensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cost"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"requestedQueryCost"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"actualQueryCost"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;46&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"throttleStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"maximumAvailable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"currentlyAvailable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;954&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"restoreRate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two numbers matter here. The &lt;code&gt;requestedQueryCost&lt;/code&gt; is what Shopify estimated before running your query, based on the fields you asked for and the &lt;code&gt;first&lt;/code&gt;/&lt;code&gt;last&lt;/code&gt; arguments on any connections. The &lt;code&gt;actualQueryCost&lt;/code&gt; is what it really cost once the query finished, and it is often lower, because a connection you asked to return 50 items might have only returned 12. Shopify refunds the difference back to your bucket automatically, so over-requesting a &lt;code&gt;first&lt;/code&gt; argument is not free, but it is not as expensive as it looks either.&lt;/p&gt;

&lt;p&gt;Want a field-by-field breakdown of where the cost comes from? Add the header &lt;code&gt;Shopify-GraphQL-Cost-Debug: 1&lt;/code&gt; to your request. The response then includes a &lt;code&gt;fields&lt;/code&gt; array. It shows the cost each part of the query adds. This is the fastest way to find the one nested connection quietly eating your whole budget.&lt;/p&gt;

&lt;p&gt;When a query costs more than your bucket can currently cover, you get an error back. The code is either &lt;code&gt;MAX_COST_EXCEEDED&lt;/code&gt; or a general throttle response. Either way, the &lt;code&gt;throttleStatus&lt;/code&gt; object tells you how many points you have left and how fast they're coming back. That's everything you need to build a sane retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query patterns that quietly cost more than they should
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F82hh9ekrbkn4a5c5hhnz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F82hh9ekrbkn4a5c5hhnz.png" alt=" " width="800" height="622"&gt;&lt;/a&gt;&lt;br&gt;
A few habits are responsible for most of the throttling issues teams run into.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unbounded or overly generous connections.&lt;/strong&gt; Every &lt;code&gt;edges { node { ... } }&lt;/code&gt; block gets sized by its &lt;code&gt;first&lt;/code&gt; or &lt;code&gt;last&lt;/code&gt; argument. Asking for &lt;code&gt;first: 250&lt;/code&gt; on a connection you only display 20 items from wastes budget on data you throw away. Request only what the page or job actually needs, and paginate deliberately instead of grabbing the maximum allowed size out of habit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep nesting.&lt;/strong&gt; Think about a query that pulls a product, then its variants, then each variant's inventory levels, then each location for those levels. Cost multiplies at every layer. Nesting isn't free just because each object only costs 1 point on its own. Flatten what you can. If a nested branch is optional data, split it into a second, shallower query instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fetching fields you don't render.&lt;/strong&gt; It's tempting to grab a wide product fragment once and reuse it everywhere, including screens that only need the title and price. Build narrower, purpose-specific fragments instead of one large one, especially for high-frequency queries like search-as-you-type or cart updates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chaining mutations without batching.&lt;/strong&gt; Since every mutation costs a flat 10 points, updating 50 products one mutation at a time costs 500 points before you've touched a single connection field. Where Shopify offers a bulk-capable mutation for the operation you need, use it. Where it doesn't, queue the writes and pace them against your restore rate instead of firing them concurrently.&lt;/p&gt;

&lt;p&gt;A useful habit: run new queries through the GraphiQL app in your Partner Dashboard first and check the &lt;code&gt;extensions.cost&lt;/code&gt; block before you ship them. It's the cheapest testing you'll ever do.&lt;/p&gt;
&lt;h2&gt;
  
  
  When to stop optimizing and use a bulk operation instead
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiv8sx9jm7h360zda0ggb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiv8sx9jm7h360zda0ggb.png" alt=" " width="800" height="440"&gt;&lt;/a&gt;&lt;br&gt;
At some point, no amount of query tuning gets a large export under the 1,000-point single-query cap. Pulling every product, variant, and inventory level for a catalog of 50,000 SKUs is that kind of job. Forcing it through paginated single queries just means more requests, more throttling, and more code to manage the pacing.&lt;/p&gt;

&lt;p&gt;Shopify's &lt;a href="https://shopify.dev/docs/api/usage/bulk-operations/queries" rel="noopener noreferrer"&gt;bulk operations API&lt;/a&gt; exists for exactly this case. You submit a query wrapped in a &lt;code&gt;bulkOperationRunQuery&lt;/code&gt; mutation, Shopify processes it asynchronously in the background, and when it finishes, you get a URL to a JSONL file with every result. Bulk operations don't count against the query-cost rate limit at all, because they run outside the normal request path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;mutation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;bulkOperationRunQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"""
    {
      products {
        edges {
          node {
            id
            title
            variants {
              edges {
                node {
                  id
                  sku
                  inventoryQuantity
                }
              }
            }
          }
        }
      }
    }
    """&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;bulkOperation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="err"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="err"&gt;status&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;userErrors&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's a simple rule of thumb. If you're pulling more than roughly 1,000 records, reach for a bulk operation. The same goes for any connection nested more than two levels deep across a full catalog. Don't just reach for a bigger &lt;code&gt;first&lt;/code&gt; argument instead. Bulk operations trade a little speed, since results aren't instant, for a rate limit ceiling that basically disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a retry strategy that respects the restore rate
&lt;/h2&gt;

&lt;p&gt;A backoff strategy that ignores &lt;code&gt;throttleStatus&lt;/code&gt; is guessing. One that reads it is calculating.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shopifyGraphQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SHOPIFY_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;adminHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;variables&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;throttle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;throttleStatus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wasThrottled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;THROTTLED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wasThrottled&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;throttle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;needed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestedQueryCost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deficit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;needed&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;throttle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentlyAvailable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;waitMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deficit&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;throttle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;restoreRate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;waitMs&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;shopifyGraphQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;variables&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="nx"&gt;data&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;This code isn't production-ready as written. A real version needs a retry limit, so it doesn't loop forever. It also needs some randomness in the wait time, so a whole fleet of workers doesn't wake up at the exact same moment and cause a new spike. But the core idea holds up: calculate the wait time from the real restore rate instead of guessing a fixed delay. Shopify recommends a one-second minimum backoff before any retry. Building your queue around the actual restore rate, instead of a flat delay, keeps your throughput closer to your true ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits into a bigger Shopify app build
&lt;/h2&gt;

&lt;p&gt;Query cost discipline matters most once an app moves past a prototype. It matters the moment you start syncing real catalog volume, inventory, or order data on a schedule. This is one of the first things worth planning for. It shouldn't be a patch you add after the first wave of 429 errors hits production. On the &lt;a href="https://www.lucentinnovation.com/services/shopify-app-development" rel="noopener noreferrer"&gt;custom Shopify app builds our team scopes&lt;/a&gt;, cost-aware pagination and bulk operations tend to be a default part of the data layer. It sits right alongside authentication and webhook handling, not as a cleanup task for later.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does a &lt;code&gt;MAX_COST_EXCEEDED&lt;/code&gt; error mean in Shopify's GraphQL API?&lt;/strong&gt;&lt;br&gt;
It means the requested cost of a single query is over the 1,000-point cap that applies to every plan. The fix is almost always to lower the &lt;code&gt;first&lt;/code&gt;/&lt;code&gt;last&lt;/code&gt; values on connections or split the query, not to wait longer, since waiting won't help a query that could never fit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does having a large &lt;code&gt;currentlyAvailable&lt;/code&gt; balance let me exceed the 1,000-point single-query cap?&lt;/strong&gt;&lt;br&gt;
No. The 1,000-point limit applies per query regardless of how much capacity is sitting in your bucket. A store with 10,000 points available still can't run a single query that costs more than 1,000.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the Storefront API rate-limited the same way?&lt;/strong&gt;&lt;br&gt;
No. Real buyer traffic on the Storefront API doesn't hit a fixed points limit. Shopify does throttle automated bot and crawler traffic on that API, according to the &lt;a href="https://shopify.dev/docs/api/usage/limits" rel="noopener noreferrer"&gt;official limits documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do REST Admin API rate limits still matter if I'm migrating to GraphQL?&lt;/strong&gt;&lt;br&gt;
Only for as long as your app keeps making REST calls. Since new public apps have been required to use GraphQL exclusively since April 1, 2025, most new builds skip REST rate limits entirely and only need to design around the query-cost model from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I request a higher GraphQL rate limit for my app?&lt;/strong&gt;&lt;br&gt;
Outside of moving to a higher Shopify plan tier, Shopify doesn't offer a general way to raise the per-app limit. The better lever is almost always reducing cost per query and offloading large reads to bulk operations.&lt;/p&gt;

&lt;p&gt;What's the most stubborn 429 you've had to chase down in a Shopify integration, and did the fix end up being a query rewrite or a queue redesign? Drop it in the comments.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>graphql</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Architecting Reliable Bulk Variant Sync for Multi-Store Shopify Setups</title>
      <dc:creator>Lucy </dc:creator>
      <pubDate>Tue, 18 Aug 2026 12:12:42 +0000</pubDate>
      <link>https://dev.to/lucy1/architecting-reliable-bulk-variant-sync-for-multi-store-shopify-setups-114a</link>
      <guid>https://dev.to/lucy1/architecting-reliable-bulk-variant-sync-for-multi-store-shopify-setups-114a</guid>
      <description>&lt;p&gt;Running one Shopify store is hard enough. Running five, twenty, or a hundred of them, all sharing one catalog, is a different kind of hard.&lt;/p&gt;

&lt;p&gt;Somewhere around store number three, a merchant's team usually hits the same wall. A color variant added in one store doesn't show up in another. A price update lands on nine out of ten storefronts. A "quick script" that worked fine last quarter starts throwing errors nobody wrote down.&lt;/p&gt;

&lt;p&gt;This isn't a coding problem. It's an architecture problem. Bulk variant sync across multiple Shopify stores has to be treated like a distributed system, because that's exactly what it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Bulk Variant Sync Actually Means
&lt;/h2&gt;

&lt;p&gt;Multi-store Shopify setups usually exist for a real business reason. A brand might run separate storefronts per region, per wholesale channel, or per sub-brand.&lt;/p&gt;

&lt;p&gt;Each store has its own product catalog, its own variant structure, and its own API rate limit. None of them know the others exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bulk variant sync&lt;/strong&gt; is the process of keeping variant data such as color, size, material, and price consistent across all of those independent catalogs, usually pushed out from one canonical source of truth.&lt;/p&gt;

&lt;p&gt;Shopify's own variant ceiling per product has moved over time, climbing from a long-standing 100-variant cap toward higher limits as newer API versions roll out. That single detail already shapes how a sync system needs to batch its writes.&lt;/p&gt;

&lt;p&gt;Picture a fashion brand running one storefront for direct-to-consumer traffic, a second for wholesale buyers, and a third for a regional market. A single new color, added to the master catalog, has to land correctly on all three, in the right currency, with the right option structure, without anyone re-typing anything by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Naive Sync Scripts Break First
&lt;/h2&gt;

&lt;p&gt;Most sync systems start life as a simple script. Loop through products, call the API, move to the next one. This works fine in testing.&lt;/p&gt;

&lt;p&gt;Under real load, three things go wrong at once.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;rate limits&lt;/strong&gt; start rejecting requests, and a script with no backoff logic just keeps hammering an API that already said no.&lt;/p&gt;

&lt;p&gt;Second, a request fails halfway through a batch. There's no clean way to tell which variants actually updated and which didn't.&lt;/p&gt;

&lt;p&gt;Third, &lt;strong&gt;retrying a failed job&lt;/strong&gt; creates duplicate variants or overwrites a value that already changed, because the retry has no memory of what it already tried.&lt;/p&gt;

&lt;p&gt;Each problem is survivable alone. Together, across ten or more stores running in parallel, they turn into a support queue full of "why does this store look different" tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency Comes Before Speed
&lt;/h2&gt;

&lt;p&gt;The fix for all three problems starts in the same place: every sync job needs to be &lt;strong&gt;idempotent&lt;/strong&gt;. Running it twice should produce the same result as running it once.&lt;/p&gt;

&lt;p&gt;Each job needs a stable identifier, built from the store, the product, and the variant, not from a timestamp or a random ID. If the same job runs again after a crash or a timeout, it should land on the same variant state instead of creating a new one.&lt;/p&gt;

&lt;p&gt;Stripe's &lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;idempotent request pattern&lt;/a&gt; is a good reference here, even outside payments. A client-generated key lets the server recognize a repeated request and return the same result, instead of processing it twice.&lt;/p&gt;

&lt;p&gt;Once jobs are idempotent, retries stop being risky. A failed job can simply run again without anyone needing to check what state it left behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Queue-First Design for Multiple Stores
&lt;/h2&gt;

&lt;p&gt;With idempotency in place, the next piece is a &lt;strong&gt;job queue&lt;/strong&gt; sitting between the source catalog and each Shopify store.&lt;/p&gt;

&lt;p&gt;Instead of one script looping through every store, a producer breaks the sync into small, single-variant jobs and pushes them onto a queue. A pool of workers pulls jobs off that queue and sends them to Shopify.&lt;/p&gt;

&lt;p&gt;This setup makes a few things much easier to manage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-store isolation:&lt;/strong&gt; one struggling store doesn't block sync to the others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controlled concurrency:&lt;/strong&gt; workers can be capped per store, so nothing overwhelms a single rate limit bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry with backoff:&lt;/strong&gt; a failed job goes back on the queue with a delay, instead of failing silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead-letter handling:&lt;/strong&gt; a job that fails repeatedly gets pulled aside for a person to review, instead of looping forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F55yvzlx4w4nc4shys7s7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F55yvzlx4w4nc4shys7s7.png" alt=" " width="799" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Teams that reach this point often find a spreadsheet-driven process, or a generic sync app, can't hold this much logic. That's usually the moment they start looking at &lt;a href="https://www.lucentinnovation.com/services/shopify-app-development" rel="noopener noreferrer"&gt;custom Shopify integration work&lt;/a&gt; instead of a one-size-fits-all plugin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Respecting Shopify's Rate Limits, Not Fighting Them
&lt;/h2&gt;

&lt;p&gt;Shopify's GraphQL Admin API doesn't count requests. It scores them. Every query has a &lt;strong&gt;cost&lt;/strong&gt;, based on the fields and connections requested, and that cost gets deducted from a bucket tied to that specific store.&lt;/p&gt;

&lt;p&gt;The bucket refills at a fixed rate. Send too much at once, and the bucket drains to zero, which triggers a throttle response instead of a result.&lt;/p&gt;

&lt;p&gt;Every response includes a cost block showing exactly how much room is left. A sync system that reads that number and slows down before hitting zero runs far smoother than one that waits for an error and reacts afterward.&lt;/p&gt;

&lt;p&gt;Each store keeps its own independent bucket. A worker pool that respects per-store limits can run many stores in parallel, without one busy store stealing capacity from another.&lt;/p&gt;

&lt;p&gt;Full details live in &lt;a href="https://shopify.dev/docs/api/usage/limits" rel="noopener noreferrer"&gt;Shopify's rate limit documentation&lt;/a&gt;, worth reading closely before writing the first retry loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Bulk Operations Are the Right Tool
&lt;/h2&gt;

&lt;p&gt;Not every sync job needs a live mutation. For very large, one-time catalog changes, like an initial migration or a seasonal reset across every store, Shopify's &lt;a href="https://shopify.dev/docs/api/usage/bulk-operations/imports" rel="noopener noreferrer"&gt;bulk operations API&lt;/a&gt; is built for exactly that.&lt;/p&gt;

&lt;p&gt;Bulk operations run asynchronously on Shopify's side. They're meant for large-scale reads and writes that would otherwise burn through a rate limit bucket in minutes.&lt;/p&gt;

&lt;p&gt;Ongoing, incremental variant sync usually fits better with direct mutations run through a queue. Big, occasional catalog overhauls usually fit better with a bulk operation.&lt;/p&gt;

&lt;p&gt;Mixing both, bulk operations for the heavy lifting and queued mutations for day-to-day updates, tends to hold up well once real merchants start using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Linked Option Trap
&lt;/h2&gt;

&lt;p&gt;One error catches almost every team building this for the first time: &lt;code&gt;CANNOT_SET_NAME_FOR_LINKED_OPTION_VALUE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It shows up when a product option, like color, is linked to a Shopify metafield or taxonomy value, and a sync job tries to set both a plain text name and a linked value at the same time.&lt;/p&gt;

&lt;p&gt;The fix is simple once it's known. Send the linked metafield reference, not the plain text name, whenever an option is linked. Skipping this check is one of the most common causes of silent, partial sync failures in multi-store catalogs.&lt;/p&gt;

&lt;p&gt;A good sync system checks each store's option configuration before writing, rather than assuming every store's "Color" option behaves the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling Out Without Breaking Live Stores
&lt;/h2&gt;

&lt;p&gt;A sync system this central to the business shouldn't go live everywhere at once.&lt;/p&gt;

&lt;p&gt;A safer path starts with one store, ideally a smaller one, running the new pipeline in a dry-run mode that logs what it would change without writing anything.&lt;/p&gt;

&lt;p&gt;Once the dry run looks clean, real writes get turned on for that single store first. Only after a few sync cycles pass without incident does the rollout move to the next store, and then the rest.&lt;/p&gt;

&lt;p&gt;This canary-style rollout catches configuration quirks, like a store with an unusual linked option setup, before they hit every storefront at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability Turns Failures Into Fixes
&lt;/h2&gt;

&lt;p&gt;None of this matters if nobody can see what happened. A queue-based sync system should log, at minimum, which job ran, which store it targeted, what it changed, and whether it succeeded.&lt;/p&gt;

&lt;p&gt;Dashboards showing queue depth, failure rate per store, and rate-limit headroom per store turn a mystery outage into a two-minute diagnosis. Alerts on a rising dead-letter count catch a broken integration long before a merchant notices missing stock on the storefront.&lt;/p&gt;

&lt;p&gt;"The moment a sync system spans more than two or three stores, retries stop being an edge case. They become the backbone of the whole design," says &lt;strong&gt;Ashish Kasama&lt;/strong&gt;, CTO at Lucent Innovation.&lt;/p&gt;

&lt;p&gt;That framing matters. A multi-store sync system isn't a script with extra error handling bolted on. It's infrastructure, and it deserves to be designed like infrastructure from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Looks Like in Production
&lt;/h2&gt;

&lt;p&gt;Put together, a reliable bulk variant sync setup usually has five moving parts: a canonical source of truth, idempotent job definitions, a queue with per-store worker limits, rate-limit-aware backoff, and a dead-letter path with real observability.&lt;/p&gt;

&lt;p&gt;None of these pieces are exotic on their own. What matters is that they exist together, from the start, instead of getting bolted on one at a time after each outage.&lt;/p&gt;

&lt;p&gt;Lucent Innovation has worked on Shopify since 2013, and has been a Shopify Plus Partner since 2016. This exact failure pattern has shown up more than once across client catalogs of very different sizes.&lt;/p&gt;

&lt;p&gt;This is the same pattern behind our mEM platform, which keeps variant data in sync across hundreds of independent merchant storefronts without anyone touching a spreadsheet.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>softwareengineering</category>
      <category>eccommerce</category>
      <category>apidesign</category>
    </item>
    <item>
      <title>Shopify Metaobjects vs Metafields: Choosing the Right Model</title>
      <dc:creator>Lucy </dc:creator>
      <pubDate>Tue, 11 Aug 2026 11:25:06 +0000</pubDate>
      <link>https://dev.to/lucy1/shopify-metaobjects-vs-metafields-choosing-the-right-model-12il</link>
      <guid>https://dev.to/lucy1/shopify-metaobjects-vs-metafields-choosing-the-right-model-12il</guid>
      <description>&lt;p&gt;Metafields add one custom field to something that already exists in Shopify: a product, an order, a customer. Metaobjects create something that didn't exist before, with its own set of fields, that other resources can point to.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Data describes one thing? Use a metafield.&lt;/li&gt;
&lt;li&gt;Data is a reusable "thing" in its own right, like a size chart, an author profile, or a set of store locations? Model it as a metaobject and reference it from wherever it needs to show up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the easy part. The part that actually costs teams time isn't picking a definition. It's picking wrong early, then discovering the mistake after forty products already depend on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the actual difference between a metafield and a metaobject?
&lt;/h2&gt;

&lt;p&gt;A metafield is a key-value pair attached directly to an existing Shopify resource. It has a namespace, a key, a type, and a value. Its full address looks like &lt;code&gt;product.metafields.custom.warranty_info&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It doesn't exist independently. Delete the product, and the metafield goes with it.&lt;/p&gt;

&lt;p&gt;A metaobject is a standalone entity. You define its shape once, then create as many entries as you need in &lt;strong&gt;Content → Metaobjects&lt;/strong&gt;. The definition covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;name&lt;/li&gt;
&lt;li&gt;fields&lt;/li&gt;
&lt;li&gt;validation rules&lt;/li&gt;
&lt;li&gt;access permissions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing about a metaobject entry ties it to a single product. A "Designer Profile" metaobject can sit unattached to anything, or be linked from fifty different products through a reference field.&lt;/p&gt;

&lt;p&gt;Shopify's own framing is the cleanest way to hold this in your head:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Metafields let you add extra columns to an existing table.&lt;/li&gt;
&lt;li&gt;Metaobjects let you create an entirely new table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're coming from a relational-database background, here's the mapping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A metaobject definition maps to a custom table.&lt;/li&gt;
&lt;li&gt;A metaobject field maps to a column on that table.&lt;/li&gt;
&lt;li&gt;A metaobject entry maps to a row.&lt;/li&gt;
&lt;li&gt;A metafield reference to a metaobject works like a foreign key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use a proper reference type (&lt;code&gt;metaobject_reference&lt;/code&gt;, &lt;code&gt;list.metaobject_reference&lt;/code&gt;) for that last one. Don't store a handle or ID in a plain text field. A plain-text pointer breaks Liquid and Storefront API resolution and can't be queried efficiently.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should you use a metafield instead of a metaobject?
&lt;/h2&gt;

&lt;p&gt;Reach for a metafield whenever the data is a genuine attribute of one resource and nobody else needs to reference it independently. Practical signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The value only makes sense in the context of the parent resource. A "care instructions" field only means something attached to a specific product.&lt;/li&gt;
&lt;li&gt;You need it to participate in admin search, filtering, or Shopify Flow. Metafield definitions support &lt;code&gt;admin_filterable&lt;/code&gt; capabilities that let you query products by metafield value directly through the GraphQL Admin API.&lt;/li&gt;
&lt;li&gt;It's a simple scalar or a short list: text, number, date, boolean, money, a single file, or a short list of these.&lt;/li&gt;
&lt;li&gt;You want to lean on Shopify's standard metafield definitions (ISBNs, care instructions, product ratings, and similar well-known fields) instead of inventing your own schema. Standard definitions don't count against your plan's metafield limits and interoperate across apps by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A warranty field, a release date on an order, an internal SKU code, a care-instructions block. All metafields. None of these needs to be looked up or reused from somewhere else in the store.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does a metaobject beat a metafield?
&lt;/h2&gt;

&lt;p&gt;Flip to a metaobject once any of these is true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The content is reusable across many resources.&lt;/strong&gt; A "Designer Profile," "Store Location," or "Size Chart" needs to show up on dozens of product pages without you retyping the bio, address, or measurements each time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The record needs more than one related field and its own identity.&lt;/strong&gt; A metaobject definition can hold up to 40 fields, each with its own type and validation. That's a scale a single metafield can't reach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a dedicated URL.&lt;/strong&gt; Metaobject definitions support a &lt;code&gt;renderable&lt;/code&gt; capability that generates a public page and SEO metadata per entry. That matters for something like an author directory you want indexed on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The relationship itself carries data.&lt;/strong&gt; Say you're modeling a many-to-many relationship where the link needs extra fields, like an ingredient with a quantity specific to one recipe. Shopify's own data-modeling guidance recommends an intermediate metaobject acting as a join table here, the same pattern as a join table in SQL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the tell that shows up in the wild most often. A merchant starts by cramming repeatable content into a long &lt;code&gt;multi_line_text_field&lt;/code&gt; metafield ("just paste the bios in as JSON"). Six months later, they need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;edit one bio without touching a blob of unstructured text&lt;/li&gt;
&lt;li&gt;filter profiles by role&lt;/li&gt;
&lt;li&gt;reuse the same profile on the About page and three product pages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a metaobject that got built as a metafield.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do metafields and metaobjects work together?
&lt;/h2&gt;

&lt;p&gt;In production, you rarely pick one exclusively. You connect them. The standard pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Define the reusable entity as a metaobject (the "table").&lt;/li&gt;
&lt;li&gt;Add a metafield on the resource that needs to point to it. Type it as &lt;code&gt;metaobject_reference&lt;/code&gt; for a one-to-one link, or &lt;code&gt;list.metaobject_reference&lt;/code&gt; for one-to-many.&lt;/li&gt;
&lt;li&gt;Read the resolved data straight from the reference in Liquid. Shopify resolves the connected metaobject automatically, so you don't run a second query.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A minimal example: a reusable "Size Chart" metaobject referenced from multiple products.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# shopify.app.toml: define the reusable entity&lt;/span&gt;
&lt;span class="nn"&gt;[metaobjects.app.size_chart]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Size Chart"&lt;/span&gt;
&lt;span class="py"&gt;display_name_field&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"chart_name"&lt;/span&gt;
&lt;span class="py"&gt;access.admin&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"merchant_read_write"&lt;/span&gt;

&lt;span class="nn"&gt;[metaobjects.app.size_chart.fields.chart_name]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Chart Name"&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"single_line_text_field"&lt;/span&gt;
&lt;span class="py"&gt;required&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[metaobjects.app.size_chart.fields.measurements]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Measurements"&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"multi_line_text_field"&lt;/span&gt;

&lt;span class="c"&gt;# Attach the reference to Product&lt;/span&gt;
&lt;span class="nn"&gt;[product.metafields.app.size_chart_ref]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Size Chart"&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"metaobject_reference&amp;lt;$app:size_chart&amp;gt;"&lt;/span&gt;
&lt;span class="py"&gt;access.admin&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"merchant_read_write"&lt;/span&gt;
&lt;span class="py"&gt;access.storefront&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"public_read"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight liquid"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;assign&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;chart&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;metafields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;size_chart_ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;chart&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
  &amp;lt;h3&amp;gt;&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;chart_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;&amp;lt;/h3&amp;gt;
  &amp;lt;p&amp;gt;&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;measurements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;&amp;lt;/p&amp;gt;
&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endif&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve products can now point at the same three size charts. Update the chart once, and every product referencing it updates in the same request. That's the behavior a plain-text metafield can't give you, because each product would be carrying its own disconnected copy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgtj7kmoe5ztzlovb4pmy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgtj7kmoe5ztzlovb4pmy.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the technical limits you'll actually hit at scale?
&lt;/h2&gt;

&lt;p&gt;Limits rarely bite during a demo. They bite eight months in, when a catalog has grown and nobody remembers the original schema decision. Here's what's currently published:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Metafields&lt;/th&gt;
&lt;th&gt;Metaobjects&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Definition cap&lt;/td&gt;
&lt;td&gt;Up to 200 definitions per resource type, per app/merchant scope&lt;/td&gt;
&lt;td&gt;128 definitions per plan (Basic/Shopify/Advanced); 256 on Plus/Enterprise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fields per definition&lt;/td&gt;
&lt;td&gt;N/A, one field, one value&lt;/td&gt;
&lt;td&gt;Up to 40 fields per definition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entry/value cap&lt;/td&gt;
&lt;td&gt;Governed by resource and plan limits&lt;/td&gt;
&lt;td&gt;Up to 1,000,000 entries per definition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reusable across resources&lt;/td&gt;
&lt;td&gt;No, bound to one resource instance&lt;/td&gt;
&lt;td&gt;Yes, referenced from any number of resources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Own URL / SEO metadata&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, with the &lt;code&gt;renderable&lt;/code&gt; capability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard access in Liquid&lt;/td&gt;
&lt;td&gt;Always accessible&lt;/td&gt;
&lt;td&gt;Must have Storefront access explicitly enabled to appear in the Storefront API (Liquid access is unaffected)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two mistakes account for most of the support-forum traffic on this topic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forgetting that a metaobject definition is capped at 40 fields, then trying to force an entire product-spec sheet into one definition instead of splitting it into related metaobjects.&lt;/li&gt;
&lt;li&gt;Forgetting to flip on Storefront API access for a metaobject definition, then wondering why a headless build returns nothing even though the same data renders fine in Liquid.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How does the choice affect Liquid and Storefront API performance?
&lt;/h2&gt;

&lt;p&gt;This is the part a lot of comparison posts skip.&lt;/p&gt;

&lt;p&gt;Reference-typed fields resolve efficiently. Shopify fetches the connected metaobject as part of the same GraphQL response, so a &lt;code&gt;metaobject_reference&lt;/code&gt; doesn't cost you a second round trip.&lt;/p&gt;

&lt;p&gt;The performance risk shows up one layer deeper, with nested references: a product referencing a metaobject that itself references another metaobject. Shopify's own data-modeling guidance flags this directly. Fetching "grandchild" data through an intermediate metaobject can hit nesting limits in the Storefront API, and it's harder to query than a flat list-of-references relationship.&lt;/p&gt;

&lt;p&gt;If you're modeling a genuine many-to-many join, expect the query cost. Cache aggressively rather than resolving nested references on every storefront request.&lt;/p&gt;

&lt;p&gt;The practical rule: keep reference chains one level deep wherever the storefront reads them on a hot path, like a PDP or collection grid. Push anything with a second layer of nesting into a build-time or cached read instead of a live per-request resolution.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the decision framework for OS 2.0 architecture?
&lt;/h2&gt;

&lt;p&gt;Run new custom-data requirements through these questions, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does this value only ever apply to one resource instance?&lt;/strong&gt; → Metafield.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it need to be edited, searched, or reused in more than one place?&lt;/strong&gt; → Metaobject.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it need more than a handful of related fields, or its own indexed page?&lt;/strong&gt; → Metaobject.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it a relationship between two things that itself carries data&lt;/strong&gt;, like a quantity, a sort order, or a date range? → Intermediate metaobject acting as a join table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it need to power admin filtering or a Shopify Flow trigger?&lt;/strong&gt; → Metafield with the relevant capability enabled, even if the value also happens to reference a metaobject.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are permanent decisions written in stone. Shopify allows changing some field-level settings after the fact. But changing a field's underlying type is tightly restricted once entries exist. The decision is far cheaper to get right at schema design time than to fix once a theme and forty products depend on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration: what happens if you chose wrong?
&lt;/h2&gt;

&lt;p&gt;The most common wrong turn is starting with a large &lt;code&gt;multi_line_text_field&lt;/code&gt; or JSON-in-a-text-field metafield for something that should have been a metaobject from day one.&lt;/p&gt;

&lt;p&gt;The fix is mechanical but not instant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create the metaobject definition with the correct fields.&lt;/li&gt;
&lt;li&gt;Backfill entries from the existing metafield values using the GraphQL Admin API. This is a scripted, one-time migration, not something to do by hand past a handful of records.&lt;/li&gt;
&lt;li&gt;Add a new &lt;code&gt;metaobject_reference&lt;/code&gt; metafield on the parent resource(s) and point it at the new entries.&lt;/li&gt;
&lt;li&gt;Update theme sections to read from the reference instead of the flat text field.&lt;/li&gt;
&lt;li&gt;Leave the old metafield in place until the new path is verified in production, then remove it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The inverse mistake, over-modeling a single-use attribute as a metaobject, is cheaper to unwind. Fold the fields back into a metafield and delete the now-unused definition.&lt;/p&gt;

&lt;p&gt;It's the direction most teams don't expect to need. That's exactly why it's worth checking before building: not every reusable-sounding field is actually reused anywhere yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can a metaobject reference another metaobject?&lt;/strong&gt;&lt;br&gt;
Yes. Reference fields can point from one metaobject to another, which is how join-table patterns work. Keep an eye on nesting depth. Storefront API queries that resolve several reference layers deep get more expensive and can hit query-cost limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do metaobjects work in Liquid the same way as the Storefront API?&lt;/strong&gt;&lt;br&gt;
Liquid can read metaobject data regardless of the Storefront API access setting. Headless storefronts built on the Storefront API need &lt;code&gt;access.storefront = "public_read"&lt;/code&gt; explicitly set on the definition, or the query returns nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a hard cap on how many metaobjects I can create?&lt;/strong&gt;&lt;br&gt;
Definitions are capped by plan: 128 on standard plans, 256 on Plus/Enterprise. Each definition can hold up to 1,000,000 entries. Standard metaobject definitions Shopify ships for things like product taxonomy don't count against that cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default that scales
&lt;/h2&gt;

&lt;p&gt;Most OS 2.0 builds settle into the same pattern once the catalog is big enough to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;metafields for the attributes that belong to one product, order, or customer&lt;/li&gt;
&lt;li&gt;metaobjects for the content that has to look the same everywhere it appears&lt;/li&gt;
&lt;li&gt;reference fields doing the connecting work in between&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting that split right before the theme is built around it is the difference between a schema change and a rebuild. It's also the baseline architecture Lucent Innovation's &lt;a href="https://www.lucentinnovation.com/services/shopify-development-agency" rel="noopener noreferrer"&gt;Shopify Development&lt;/a&gt; team designs into every custom OS 2.0 storefront, so merchant content teams aren't stuck re-modeling data six months after launch.&lt;/p&gt;

&lt;p&gt;What's the messiest metafield-vs-metaobject call you've had to untangle on a live store?&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Debugging Shopify Webhook Delivery Failures: A Developer Checklist</title>
      <dc:creator>Lucy </dc:creator>
      <pubDate>Tue, 04 Aug 2026 13:05:09 +0000</pubDate>
      <link>https://dev.to/lucy1/debugging-shopify-webhook-delivery-failures-a-developer-checklist-5f28</link>
      <guid>https://dev.to/lucy1/debugging-shopify-webhook-delivery-failures-a-developer-checklist-5f28</guid>
      <description>&lt;p&gt;&lt;strong&gt;Quick answer:&lt;/strong&gt; Most "missing webhook" bugs on Shopify aren't Shopify's fault. Your endpoint either takes longer than 5 seconds to respond, fails HMAC verification because the body was already parsed, or processes the same delivery twice because there's no dedupe check. Shopify retries a failed delivery 8 times over 4 hours with exponential backoff, then drops the event for good. This post walks through how to find out which of those it actually is, using Shopify's own delivery logs instead of guesswork.&lt;/p&gt;

&lt;p&gt;If you've ever had a merchant email you about an order that "never showed up" in your system, only to find Shopify's dashboard says the order exists and the webhook fired, this is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually causes Shopify webhook delivery failures?
&lt;/h2&gt;

&lt;p&gt;In practice, almost every failed delivery falls into one of five buckets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Timeout.&lt;/strong&gt; Your endpoint didn't return a &lt;code&gt;200&lt;/code&gt; within 5 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signature mismatch.&lt;/strong&gt; HMAC verification failed, usually because the raw body was mutated before verification ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent duplicate processing.&lt;/strong&gt; The same event arrived twice and got processed twice, which looks like a data bug but is really a missing dedupe check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscription removal.&lt;/strong&gt; Repeated failures caused Shopify to auto-delete the subscription, so you stopped receiving anything and didn't notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale payload assumptions.&lt;/strong&gt; A retried webhook carries the &lt;em&gt;original&lt;/em&gt; payload from when it was first triggered, not the current state, so code that assumes "this payload is always fresh" quietly does the wrong thing.
None of these show up as a stack trace in your own logs. They show up as a missing order, a duplicate charge, or a support ticket. The fastest way to tell them apart is to stop guessing and pull Shopify's own delivery data first, which is covered a few sections down.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How does Shopify's retry system actually work?
&lt;/h2&gt;

&lt;p&gt;Shopify's current policy, in place since a September 2024 update, retries a failed webhook delivery up to 8 times over a 4-hour window using exponential backoff. A &lt;code&gt;200&lt;/code&gt;-series response counts as success. Anything else, including a &lt;code&gt;3xx&lt;/code&gt; redirect, counts as a failure and queues a retry. After the 8th failed attempt, Shopify stops trying and the event is gone unless you reconstruct it yourself from the Admin API.&lt;/p&gt;

&lt;p&gt;Shopify doesn't publish the exact per-attempt schedule, only the total shape (8 attempts, 4 hours, exponential backoff), so treat the chart below as directional rather than a literal timetable. If you're reading an older tutorial describing a much longer retry window, it predates this change.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiia8fsauo8vw3u8zsv6h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiia8fsauo8vw3u8zsv6h.png" alt=" " width="799" height="317"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two details in the &lt;a href="https://shopify.dev/changelog/updates-to-webhook-retry-mechanism" rel="noopener noreferrer"&gt;retry mechanism changelog&lt;/a&gt; matter more than the headline number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retried deliveries reuse the original payload and address.&lt;/strong&gt; If you change your endpoint URL mid-retry-cycle, the retry still goes to the old address. Keep the old endpoint alive for a while during any migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;X-Shopify-Triggered-At&lt;/code&gt;, not "now," to judge freshness.&lt;/strong&gt; If your handler assumes every delivery reflects the current state of the resource, a late retry will overwrite newer data with older data.
## Why do webhooks time out even when your server is healthy?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the failure mode that confuses people most, because their server looks fine in every other metric. According to &lt;a href="https://shopify.dev/docs/apps/build/webhooks/verify-deliveries" rel="noopener noreferrer"&gt;Shopify's guidance on verifying deliveries&lt;/a&gt;, Shopify allows a one-second connection timeout and a five-second timeout for the entire request-response cycle. That five seconds has to cover TLS handshake, routing through any load balancer or serverless cold start, your handler's logic, and the response write.&lt;/p&gt;

&lt;p&gt;The usual culprit is doing real work inside the request handler: writing to a database, calling a third-party API, resizing an image, or running any business logic before responding. If any of that occasionally takes more than a couple of seconds, you'll see intermittent failures that look mysterious, because the work usually finishes anyway, just after Shopify already marked the delivery as failed and queued a retry.&lt;/p&gt;

&lt;p&gt;The fix Shopify itself recommends is to treat the webhook endpoint as a thin acknowledgment layer and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// webhook-receiver.js&lt;/span&gt;
&lt;span class="c1"&gt;// Keep this handler doing almost nothing. Verify, queue, respond.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*/*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SHOPIFY_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-shopify-hmac-sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isValid&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Hand the raw payload to a queue and return immediately.&lt;/span&gt;
  &lt;span class="c1"&gt;// Do NOT touch a database or call another API before this line.&lt;/span&gt;
  &lt;span class="nx"&gt;jobQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shopify-webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;webhookId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-shopify-webhook-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-shopify-topic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;triggeredAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-shopify-triggered-at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&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;A background worker then pulls from that queue and does the actual database write or API call, on its own schedule, with no 5-second clock running. This single change eliminates most timeout-related failures without touching your retry or business logic at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyobxbu9bkqugmk46fuk0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyobxbu9bkqugmk46fuk0.png" alt=" " width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you verify a delivery actually came from Shopify?
&lt;/h2&gt;

&lt;p&gt;Every HTTPS delivery includes a base64-encoded HMAC-SHA256 signature in the &lt;code&gt;X-Shopify-Hmac-Sha256&lt;/code&gt; header, computed from your app's client secret and the raw request body, per &lt;a href="https://shopify.dev/docs/apps/build/webhooks/verify-deliveries" rel="noopener noreferrer"&gt;Shopify's verification docs&lt;/a&gt;. Skipping this check, or getting it wrong, is the second most common source of "delivery failed" errors after timeouts.&lt;/p&gt;

&lt;p&gt;Three mistakes account for almost all HMAC verification bugs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Body already parsed.&lt;/strong&gt; If &lt;code&gt;express.json()&lt;/code&gt; or an equivalent middleware runs before your verification code, it has already mutated the body you need to hash. Verification must happen against the exact raw bytes Shopify sent, which means your raw-body middleware has to run first, not your JSON parser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;String comparison instead of constant-time comparison.&lt;/strong&gt; Comparing signatures with &lt;code&gt;===&lt;/code&gt; leaks timing information. Use &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; (or your language's equivalent) instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verifying against a secret that just rotated.&lt;/strong&gt; If you rotate your app's client secret, Shopify notes it can take up to an hour before the HMAC digest is generated with the new secret. A verification failure right after a rotation isn't necessarily a bug in your code.
Treat header names as case-insensitive in your code. Shopify documents that HTTP/2 often lowercases them, so &lt;code&gt;X-Shopify-Hmac-Sha256&lt;/code&gt; and &lt;code&gt;x-shopify-hmac-sha256&lt;/code&gt; need to resolve to the same value in your handler.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why are you getting duplicate webhook deliveries?
&lt;/h2&gt;

&lt;p&gt;Shopify's own documentation is direct about this: it minimizes duplicate deliveries but doesn't guarantee exactly-once delivery, so your app might receive the same webhook more than once, for example after a network timeout that happens right as your response is in flight. If your database write isn't idempotent, a duplicate delivery becomes a duplicate order note, a double-counted inventory adjustment, or a second email to a customer.&lt;/p&gt;

&lt;p&gt;Two headers, &lt;a href="https://shopify.dev/docs/apps/build/webhooks/delivery-structure" rel="noopener noreferrer"&gt;documented in Shopify's delivery structure reference&lt;/a&gt;, matter here, and mixing them up is a common source of confusion:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;What it identifies&lt;/th&gt;
&lt;th&gt;Use it to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A unique key per individual delivery&lt;/td&gt;
&lt;td&gt;Deduplicate a single delivery you may have already processed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Shopify-Event-Id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shared across every delivery triggered by the same merchant action&lt;/td&gt;
&lt;td&gt;Correlate deliveries across multiple subscriptions to the same topic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you have more than one subscription listening to the same topic, you'll get a separate delivery, with a separate &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt;, for each one. Dedupe on the webhook ID, not the event ID, or you'll silently drop deliveries you actually needed.&lt;/p&gt;

&lt;p&gt;The dedupe check itself is simple: before processing, look up the &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt; in whatever persistent store you're already using (Redis, a database table, whatever fits your stack). If it's been seen, skip processing and return success anyway, since Shopify already got the acknowledgment it needed. If it's new, save the ID and process it.&lt;/p&gt;

&lt;p&gt;We've hit this exact problem doing &lt;a href="https://www.lucentinnovation.com/services/shopify-app-development" rel="noopener noreferrer"&gt;custom Shopify webhook integration development&lt;/a&gt; for merchants pushing orders into an external system in near real time. The failure mode is never dramatic. It's a handful of orders that get written twice into an ERP because a retry landed a few seconds after the original delivery finished processing, and nobody notices until someone reconciles the numbers weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you read Shopify's delivery logs before building your own?
&lt;/h2&gt;

&lt;p&gt;If your app was created through the Dev Dashboard or Shopify CLI, &lt;a href="https://shopify.dev/docs/apps/build/webhooks/troubleshoot" rel="noopener noreferrer"&gt;Shopify's troubleshooting guide&lt;/a&gt; points you to a delivery metrics report before you write a single line of custom monitoring. It's worth using this first, because it already has the data you'd otherwise have to reconstruct from your own logs.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Monitoring&lt;/strong&gt; page (Dev Dashboard → your app → Monitoring) shows, per topic, over the last 7 days:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total deliveries and failed delivery rate&lt;/li&gt;
&lt;li&gt;Response time at the 90th percentile&lt;/li&gt;
&lt;li&gt;&lt;p&gt;How many webhook subscriptions have been auto-removed&lt;br&gt;
Shopify's own guidance on what counts as a real problem is specific enough to skip a lot of debate:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A &lt;strong&gt;failed delivery rate over 0.5%&lt;/strong&gt; is higher than average and worth investigating.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Response times sitting between 4 and 5 seconds&lt;/strong&gt; mean you're right at the timeout edge, not comfortably under it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If &lt;strong&gt;every topic&lt;/strong&gt; has a high failure rate at once, the problem is your backend being down, not any single handler.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If &lt;strong&gt;removed webhooks&lt;/strong&gt; shows a nonzero count, you have subscriptions that stopped delivering and nobody re-created them.&lt;br&gt;
The &lt;strong&gt;Logs&lt;/strong&gt; page lets you inspect an individual delivery: response code, response time, payload size, delivery attempt number, and the HMAC signature that was sent. That last field is genuinely useful for a specific class of bug: when your local testing works, production HMAC verification doesn't, and you need to confirm whether the signature Shopify sent even matches what you're computing.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What happens after Shopify removes your webhook subscription?
&lt;/h2&gt;

&lt;p&gt;This is the failure mode that does the most silent damage, because nothing errors, you just... stop getting data. If deliveries to a subscription keep failing, Shopify auto-removes it, and warning emails go to your app's emergency developer email address, which is easy to have pointed at an inbox nobody checks.&lt;/p&gt;

&lt;p&gt;Recovery depends on how the subscription was created:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App-specific subscriptions&lt;/strong&gt; (declared in &lt;code&gt;shopify.app.toml&lt;/code&gt; and deployed with your app) don't need manual re-subscription; they're tied to the app's own configuration rather than a one-off API call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shop-specific subscriptions&lt;/strong&gt; (created through the Admin API for an individual merchant) need to be recreated, and Shopify recommends checking existing subscriptions first so you only create what's missing.
Either way, re-subscribing only fixes the going-forward problem. The gap in data during the outage still has to be backfilled by fetching the missing records from the Admin API and feeding them back through your normal processing path. This is the part teams skip, and it's the part that actually prevents a customer support ticket three weeks later asking why an order from last month never triggered a fulfillment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A quick diagnostic checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;What to check first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deliveries fail intermittently, server looks fine&lt;/td&gt;
&lt;td&gt;Handler doing real work before responding&lt;/td&gt;
&lt;td&gt;Response time in delivery logs, especially the 4-5s range&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All deliveries return 401&lt;/td&gt;
&lt;td&gt;HMAC secret mismatch or wrong raw body&lt;/td&gt;
&lt;td&gt;Confirm raw-body middleware runs before any JSON parser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same order processed twice&lt;/td&gt;
&lt;td&gt;No dedupe check&lt;/td&gt;
&lt;td&gt;Add a store keyed on &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data stopped arriving entirely, no errors anywhere&lt;/td&gt;
&lt;td&gt;Subscription auto-removed after repeated failures&lt;/td&gt;
&lt;td&gt;Dev Dashboard "Removed webhooks" metric, emergency developer email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old data overwriting new data after an outage&lt;/td&gt;
&lt;td&gt;Retry delivered a stale, original payload&lt;/td&gt;
&lt;td&gt;Compare &lt;code&gt;X-Shopify-Triggered-At&lt;/code&gt; against current record timestamp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works locally, fails in production&lt;/td&gt;
&lt;td&gt;Different secret, or a proxy mutating the body&lt;/td&gt;
&lt;td&gt;Re-verify HMAC using the exact production request body&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Building retry logic that survives a longer outage
&lt;/h2&gt;

&lt;p&gt;Shopify's 4-hour retry window is generous for a blip, not for a real outage. If your own deployment goes down for longer than that, you need your own resilience layer on top of Shopify's, not instead of it. The pattern that holds up in production, echoed in &lt;a href="https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/" rel="noopener noreferrer"&gt;AWS's canonical guidance on backoff and jitter&lt;/a&gt; and its &lt;a href="https://docs.aws.amazon.com/wellarchitected/latest/framework/rel_mitigate_interaction_failure_limit_retries.html" rel="noopener noreferrer"&gt;Well-Architected Framework retry guidance&lt;/a&gt;, comes down to a short list: acknowledge fast, classify errors so only transient ones get retried, add jitter so recovering clients don't all retry in the same instant, cap the number of attempts, and reconcile against the source of truth afterward rather than assuming your queue caught everything.&lt;/p&gt;

&lt;p&gt;None of that is Shopify-specific. It's the same reason exponential backoff without jitter still clusters retries into waves, and why a dead letter queue matters more than a longer retry window.&lt;/p&gt;

&lt;p&gt;This is exactly why we hit similar sync issues building custom order-sync middleware for a manufacturing client, connecting Shopify to an ERP and a 3PL provider. Retry tuning helped. A reconciliation job that periodically compared both systems and caught what fell through is what actually stopped orders from silently going missing.&lt;/p&gt;




&lt;p&gt;What's the strangest webhook failure you've had to track down, and did Shopify's own delivery logs actually explain it, or did you end up debugging blind? Drop it in the comments, I'd like to compare notes.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>webhooks</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Shopify AI Search Optimization: How to Make Your Store Visible Inside ChatGPT, Perplexity, and Google AI Mode</title>
      <dc:creator>Lucy </dc:creator>
      <pubDate>Wed, 29 Jul 2026 06:22:47 +0000</pubDate>
      <link>https://dev.to/lucy1/shopify-ai-search-optimization-how-to-make-your-store-visible-inside-chatgpt-perplexity-and-4d45</link>
      <guid>https://dev.to/lucy1/shopify-ai-search-optimization-how-to-make-your-store-visible-inside-chatgpt-perplexity-and-4d45</guid>
      <description>&lt;p&gt;&lt;em&gt;Direct answer: Shopify AI search optimization (GEO) means structuring your product and content pages so ChatGPT, Perplexity, Google AI Mode, and Copilot can read, trust, and cite them. In practice that comes down to three things: clean schema.org markup on every product page, a healthy Google/Bing Merchant Center feed, and content written in a self-contained, fact-dense style that AI systems can lift and quote. Below is exactly how the major platforms pull Shopify product data in 2026, and a checklist you can run today.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Shopify Merchants Can't Ignore AI Search Anymore
&lt;/h2&gt;

&lt;p&gt;For most of the last decade, "getting found" meant ranking in ten blue links. That's no longer the whole game. Shoppers now ask ChatGPT to compare running shoes, ask Perplexity what the best gift is for a new homeowner, and ask Google AI Mode to shortlist skincare brands before they ever open a browser tab to search normally.&lt;/p&gt;

&lt;p&gt;The interesting part is that this isn't a hypothetical shift for Shopify merchants specifically. Shopify has already built the plumbing for it. Shopify Catalog automatically syndicates product data to connected AI platforms including Perplexity, ChatGPT, and Google AI Mode, and when a shopper completes a purchase through one of these assistants, checkout still happens on the merchant's own store using their existing payment setup, according to &lt;a href="https://www.shopify.com/blog/perplexity-shopping" rel="noopener noreferrer"&gt;Shopify's own guide to Perplexity Shopping&lt;/a&gt;. In other words, the infrastructure exists. Most stores just aren't feeding it correctly.&lt;/p&gt;

&lt;p&gt;That's the gap this post covers: what "AI search optimization" (often called Generative Engine Optimization, or GEO) actually means for a Shopify store, how each AI platform sources its data, and what a merchant or their dev team should fix first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do AI Shopping Assistants Actually Find Your Products?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi1qn3ozabxxflauuap6v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi1qn3ozabxxflauuap6v.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
This is the part most merchants get wrong, because it's easy to assume ChatGPT "crawls your site" the way Googlebot does. It mostly doesn't. Each platform has a different pipeline, and knowing which one matters for you changes where you spend effort.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ChatGPT Shopping&lt;/strong&gt; draws roughly 83% of its product recommendations from Google Shopping's organic index, based on a March 2026 analysis of 43,000 carousel products that found base64-encoded Google Shopping parameters embedded in ChatGPT's source code, confirming a direct pipeline from Google Merchant Center feeds into ChatGPT's results, according to &lt;a href="https://www.getpassionfruit.com/blog/how-to-optimize-product-feeds-for-chatgpt-shopping-perplexity-and-ai-commerce" rel="noopener noreferrer"&gt;a feed-optimization breakdown of that research&lt;/a&gt;. Shopify merchants in the US get automatic catalog syndication into ChatGPT through Shopify's own Agentic Storefront integration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Perplexity Shopping&lt;/strong&gt; is closer to a real-time researcher. It crawls your live HTML and cites the exact page it pulled facts from, and its shoppers convert at a notably higher average order value than other AI referral traffic, per Shopify's Perplexity guide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google AI Mode&lt;/strong&gt; leans on the existing Shopping Graph, meaning your Google Merchant Center feed and on-page schema still do most of the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Copilot&lt;/strong&gt; runs on Bing Merchant Center and, as of a January 2026 rollout, offers checkout inside the chat window itself for eligible Shopify merchants, per &lt;a href="https://aiadvantageagency.com/ai-shopping-platforms-for-ecommerce/" rel="noopener noreferrer"&gt;an ecommerce AI platform comparison&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Primary data source&lt;/th&gt;
&lt;th&gt;What actually moves the needle&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT Shopping&lt;/td&gt;
&lt;td&gt;Google Shopping feed (via Shopify Agentic Storefront)&lt;/td&gt;
&lt;td&gt;Clean Merchant Center feed, GTIN, full descriptions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Perplexity Shopping&lt;/td&gt;
&lt;td&gt;Live HTML crawl + Shopify Catalog&lt;/td&gt;
&lt;td&gt;On-page schema, real prices, return policy fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google AI Mode&lt;/td&gt;
&lt;td&gt;Shopping Graph&lt;/td&gt;
&lt;td&gt;Merchant Center feed + Product/Offer schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft Copilot&lt;/td&gt;
&lt;td&gt;Bing Merchant Center&lt;/td&gt;
&lt;td&gt;Feed hygiene + Copilot Checkout enrollment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The practical takeaway: your Google Merchant Center feed and your on-page schema aren't separate SEO and "AI SEO" tasks anymore. They're the same infrastructure two different systems are reading.&lt;/p&gt;
&lt;h2&gt;
  
  
  What Structured Data Does an AI Engine Actually Need From a Product Page?
&lt;/h2&gt;

&lt;p&gt;Structured data is how you tell a machine that "$89" is a price and "4.7/5" is a rating, rather than making it guess from surrounding text. Most Shopify themes add basic Product schema automatically, but an audit of 2,400 Shopify product pages found only 9% carried the structured data required for ChatGPT or Perplexity to reliably recommend them, per &lt;a href="https://wrkngdigital.com/post/how-shopify-stores-get-recommended-by-chatgpt-and-perplexity-in-2026" rel="noopener noreferrer"&gt;an analysis of Shopify AI visibility&lt;/a&gt;. The gap is usually in the fields nobody thinks about: shipping details, return policy, and review counts.&lt;/p&gt;

&lt;p&gt;A reasonably complete Product schema block looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://schema.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Product"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Product Name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://cdn.shopify.com/example.jpg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A full, specific product description, not a five-word title restated."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"brand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Brand"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Your Brand"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SKU-1234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gtin13"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0012345678905"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"offers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Offer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"priceCurrency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"89.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"availability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://schema.org/InStock"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"shippingDetails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OfferShippingDetails"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"shippingRate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MonetaryAmount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hasMerchantReturnPolicy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MerchantReturnPolicy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"returnPolicyCategory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://schema.org/MerchantReturnFiniteReturnWindow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"merchantReturnDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aggregateRating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AggregateRating"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ratingValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4.7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reviewCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"182"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fields worth flagging specifically because they're the ones most themes skip: &lt;code&gt;hasMerchantReturnPolicy&lt;/code&gt; and &lt;code&gt;shippingDetails&lt;/code&gt;. Shipping and return terms are now a comparison criterion AI assistants use to decide between two similar products, not just legal boilerplate buried in a footer link.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zoxdvrsx757nqta7mcg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zoxdvrsx757nqta7mcg.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Does llms.txt Actually Help a Shopify Store Get Cited?
&lt;/h2&gt;

&lt;p&gt;This one deserves an honest answer rather than a hype-cycle one. llms.txt is a proposed standard, introduced in September 2024 by Jeremy Howard of Answer.AI, that puts a curated Markdown map of a site's key pages at the domain root, similar in spirit to robots.txt but aimed at language models instead of search crawlers, as documented in &lt;a href="https://searchengineland.com/llms-txt-proposed-standard-453676" rel="noopener noreferrer"&gt;Search Engine Land's coverage of the proposal&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It has real traction in one specific use case: developer documentation for coding assistants and AI agents. It is far shakier as a general AI-search ranking lever. One large-scale analysis of AI bot traffic found that requests to &lt;code&gt;/llms.txt&lt;/code&gt; were statistically negligible among the user agents that actually drive citations, such as GPTBot, ClaudeBot, and PerplexityBot, meaning most AI systems answering shopping questions in 2026 simply aren't reading it yet.&lt;/p&gt;

&lt;p&gt;The pragmatic move for a Shopify store: add a lightweight llms.txt if it takes an hour, since there's no real downside, but don't treat it as a substitute for the fundamentals above. Structured data and feed hygiene are doing the actual work right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Should Product and Blog Content Be Written So AI Engines Want to Quote It?
&lt;/h2&gt;

&lt;p&gt;Generative engines retrieve short passages, not whole pages, so the unit of content that gets cited is a sentence or two, not a paragraph. Research from Princeton, Georgia Tech, and IIT Delhi found that content optimized for this pattern achieved measurably higher visibility in AI-generated answers, with the biggest single gains coming from adding verifiable statistics to a passage and from leading with a definition-first sentence rather than a scene-setting one, according to &lt;a href="https://red-engage.com/blog/generative-engine-optimization" rel="noopener noreferrer"&gt;a summary of that research&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What that looks like in practice on a Shopify blog or PDP:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lead with the answer.&lt;/strong&gt; Put the direct, factual sentence first in every section, something like "A composting bin needs airflow on at least two sides," before the supporting explanation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make each paragraph stand alone.&lt;/strong&gt; Assume an AI system will lift that one paragraph out of context and quote it. Don't rely on "as mentioned above."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use question-style H2s.&lt;/strong&gt; "What size composting bin do I need for a family of four?" mirrors how people actually phrase prompts, far more than "Composting Bin Sizes."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cite something verifiable.&lt;/strong&gt; A specific number, a named study, or a dated fact gets picked up far more often than a general claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh the page.&lt;/strong&gt; AI engines weigh recency when choosing which source to cite, so a guide last touched in 2023 loses ground to a competitor's 2026 update on the same topic.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A Practical GEO Checklist for Shopify Merchants
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Audit every product page for &lt;code&gt;Offer&lt;/code&gt;, &lt;code&gt;AggregateRating&lt;/code&gt;, &lt;code&gt;MerchantReturnPolicy&lt;/code&gt;, and &lt;code&gt;OfferShippingDetails&lt;/code&gt; schema, not just basic Product markup.&lt;/li&gt;
&lt;li&gt;Check your Google Merchant Center and Bing Merchant Center feeds for stale "in stock" flags. This is one of the fastest ways to get a feed quality-flagged and dropped from AI shopping results.&lt;/li&gt;
&lt;li&gt;Confirm your robots.txt isn't accidentally blocking GPTBot, PerplexityBot, or OAI-SearchBot. Cloudflare's default configuration change in 2025 caused a wave of sites to block AI bots without realizing it.&lt;/li&gt;
&lt;li&gt;Make sure product pages render server-side. AI crawlers generally don't execute heavy client-side JavaScript the way a human browser does.&lt;/li&gt;
&lt;li&gt;Write full, specific product descriptions. A 15-word description gives an AI system almost nothing to reason over when it's matching your product to a conversational query.&lt;/li&gt;
&lt;li&gt;Republish or refresh cornerstone guides at least twice a year with a visible "last updated" date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuuo49a4832911h3f3ceh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuuo49a4832911h3f3ceh.png" alt=" " width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should Actually Run This Audit?
&lt;/h2&gt;

&lt;p&gt;None of the above is exotic engineering, but it does touch theme templates, feed configuration, and content strategy at the same time, which is exactly where things get missed on a live store. This is the kind of cross-cutting technical and content work a team of &lt;a href="https://www.lucentinnovation.com/services/shopify-expert-agency" rel="noopener noreferrer"&gt;dedicated Shopify specialists&lt;/a&gt; is set up to audit and implement properly, because it means checking schema output against the live theme, not just the store's settings panel, and tying that back into how product copy is actually written.&lt;/p&gt;

&lt;p&gt;If you're not sure where your own store stands on any of the checklist above, that's usually the first thing worth having someone independently look at before you spend a quarter chasing individual AI platforms one by one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does GEO replace traditional SEO for a Shopify store?&lt;/strong&gt;&lt;br&gt;
No. GEO builds on the same technical foundation as SEO (crawlability, structured data, fast pages) and adds a layer on top for how AI systems retrieve and cite content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which AI shopping platform should a Shopify merchant prioritize first?&lt;/strong&gt;&lt;br&gt;
Start with your Google Merchant Center feed. It feeds Google Shopping, Google AI Mode, and indirectly a large share of ChatGPT Shopping's results, so fixing it once pays off across multiple platforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is llms.txt required for a Shopify store?&lt;/strong&gt;&lt;br&gt;
No. It's optional and low-cost to add, but current data suggests the major AI shopping crawlers aren't reading it in meaningful volume yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does it take to see AI citations after making these changes?&lt;/strong&gt;&lt;br&gt;
Perplexity, which relies on real-time web retrieval, can pick up freshly published or updated content within days. Feed-based platforms like ChatGPT Shopping and Google AI Mode typically lag behind the next feed refresh cycle, which can take longer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you checked whether your own product pages actually render their schema correctly once the theme's JavaScript runs? Curious what others are finding when they audit their live Shopify stores for this. Drop what you find in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>shopifyseo</category>
      <category>ai</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Shopify Checkout UI Extension Development: A Step-by-Step Guide (2026)</title>
      <dc:creator>Lucy </dc:creator>
      <pubDate>Tue, 21 Jul 2026 07:05:17 +0000</pubDate>
      <link>https://dev.to/lucy1/shopify-checkout-ui-extension-development-a-step-by-step-guide-2026-441f</link>
      <guid>https://dev.to/lucy1/shopify-checkout-ui-extension-development-a-step-by-step-guide-2026-441f</guid>
      <description>&lt;p&gt;Shopify checkout UI extension development means building a small, sandboxed React or Preact component that Shopify renders inside a fixed slot in checkout, such as a block on the Thank you page or a static area next to the cart line items. This guide scaffolds one from a real Shopify CLI project, reads and writes checkout data through the current &lt;code&gt;shopify&lt;/code&gt; global object, tests it locally, and deploys it under the platform's actual constraints. By the end you'll have a working delivery-instructions extension and a clear list of the places these extensions tend to fail once real customers start using them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You're Building (and What This Guide Skips)
&lt;/h2&gt;

&lt;p&gt;The extension in this walkthrough adds a text field to checkout where a customer can leave a note for the courier, and it shows the current order subtotal above the field. That's deliberately small. It exercises the three things almost every checkout UI extension needs: a target, a read from the &lt;code&gt;shopify&lt;/code&gt; object, and a write back to the order.&lt;/p&gt;

&lt;p&gt;This guide does not cover Shopify Functions (server-side discount, shipping, and payment logic), the Branding API (colors, fonts, and layout), or post-purchase upsell offers. Those are real parts of checkout extensibility, but they solve different problems and deserve their own walkthroughs. Shopify maintains its own &lt;a href="https://shopify.dev/docs/apps/build/checkout/fields-banners/add-field" rel="noopener noreferrer"&gt;custom fields tutorial&lt;/a&gt; covering a similar delivery-instructions pattern, worth a look once you want the officially maintained reference version alongside this one. The next section explains how to tell which one you actually need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checkout UI Extensions vs. Shopify Functions: Which One Do You Actually Need?
&lt;/h2&gt;

&lt;p&gt;If the customer needs to see or interact with something, you want a UI extension. If you need to change a price, a discount, a shipping rate, or which payment methods appear, you want a Function. UI extensions render interface inside a sandboxed environment; Functions run server-side with no rendering at all, according to &lt;a href="https://shopify.dev/docs/apps/build/checkout" rel="noopener noreferrer"&gt;Shopify's documentation on apps in checkout&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Checkout UI Extensions&lt;/th&gt;
&lt;th&gt;Shopify Functions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Renders visible UI&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs where&lt;/td&gt;
&lt;td&gt;Client-side, inside a Web Worker sandbox&lt;/td&gt;
&lt;td&gt;Server-side, on Shopify's infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical use&lt;/td&gt;
&lt;td&gt;Custom fields, banners, upsell blocks, trust badges&lt;/td&gt;
&lt;td&gt;Discount logic, shipping rate changes, payment method ordering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data access&lt;/td&gt;
&lt;td&gt;Reads/writes cart and checkout data through the &lt;code&gt;shopify&lt;/code&gt; object&lt;/td&gt;
&lt;td&gt;Reads structured input, returns structured output, no direct cart mutation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plan requirement&lt;/td&gt;
&lt;td&gt;Available on all plans; the information, shipping, and payment steps still require Shopify Plus&lt;/td&gt;
&lt;td&gt;Available broadly, most often paired with Plus-tier checkout customization&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Teams often reach for a UI extension when the real problem is a Function, which is why a custom field ends up trying to also silently change a shipping rate through a side channel. Keep the two separate. If your logic doesn't need to be seen, it probably belongs in a Function instead.&lt;/p&gt;

&lt;p&gt;It's also worth knowing what these two tools replaced. Shopify Scripts, the old way to write custom discount and shipping logic for Plus stores, stopped executing on June 30, 2026, after running alongside checkout extensions during a transition window that &lt;a href="https://shopify.dev/docs/apps/build/checkout#upgrade" rel="noopener noreferrer"&gt;Shopify's own documentation confirms&lt;/a&gt;. Anything still living in an old Script needs to move to a Function now, not a UI extension.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;You'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js and a recent Shopify CLI install&lt;/li&gt;
&lt;li&gt;A Partner account and a development store with checkout extensibility enabled&lt;/li&gt;
&lt;li&gt;An existing custom or public app to attach the extension to (or scaffold a new one with &lt;code&gt;shopify app init&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set the API version to the latest stable release in your configuration file rather than pinning to whatever version a tutorial used months ago. Each stable version stays supported for a minimum of 12 months, and the CLI blocks deploys against versions older than that window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaffold the Extension
&lt;/h2&gt;

&lt;p&gt;From inside your app directory, generate the extension:&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;cd &lt;/span&gt;my-app
shopify app generate extension &lt;span class="nt"&gt;--template&lt;/span&gt; checkout_ui
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a folder with a &lt;code&gt;shopify.extension.toml&lt;/code&gt; configuration file and a templated JSX entry point. Edit the TOML to point at a block target, which is the placement type merchants can position themselves using the checkout editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;api_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2026-07"&lt;/span&gt;

&lt;span class="nn"&gt;[[extensions]]&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ui_extension"&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Delivery Notes"&lt;/span&gt;
&lt;span class="py"&gt;handle&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"delivery-notes"&lt;/span&gt;
&lt;span class="py"&gt;uid&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"generate-this-with-shopify-cli"&lt;/span&gt;

  &lt;span class="nn"&gt;[[extensions.targeting]]&lt;/span&gt;
  &lt;span class="py"&gt;target&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"purchase.checkout.block.render"&lt;/span&gt;
  &lt;span class="py"&gt;module&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"./src/DeliveryNotes.jsx"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Block targets are one of &lt;a href="https://shopify.dev/docs/api/checkout-ui-extensions/2026-07/targets" rel="noopener noreferrer"&gt;three target types&lt;/a&gt;. Static targets render automatically in a fixed spot, like right after the cart line items, and can't be repositioned. Runnable targets don't render anything at all; they fire in response to an event, such as a keystroke in an address field, and return data. Block targets sit in between: merchants place them through the &lt;a href="https://shopify.dev/docs/api/checkout-ui-extensions/latest" rel="noopener noreferrer"&gt;checkout editor&lt;/a&gt;, and up to three extensions can share the same slot. The &lt;a href="https://shopify.dev/docs/api/shopify-cli" rel="noopener noreferrer"&gt;Shopify CLI&lt;/a&gt; documentation covers the full set of scaffold and dev-server flags if you need to target a specific store or app configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read Checkout Data With the &lt;code&gt;shopify&lt;/code&gt; Global Object
&lt;/h2&gt;

&lt;p&gt;Inside the extension, Shopify injects a global &lt;code&gt;shopify&lt;/code&gt; object that exposes checkout data as properties. There's no &lt;code&gt;import&lt;/code&gt; needed for it: it's simply available at runtime, similar to how &lt;code&gt;window&lt;/code&gt; behaves in a normal browser page, except this one runs inside a Web Worker with no DOM access.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@shopify/ui-extensions/preact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preact/hooks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extension&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DeliveryNotes&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;,&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DeliveryNotes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setNote&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subtotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;shopify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtotalAmount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;s&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="na"&gt;stack&lt;/span&gt; &lt;span class="na"&gt;border&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"base"&lt;/span&gt; &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;s&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="na"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Delivery instructions&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;s&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="na"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;s&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"small"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        Subtotal: &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currencyCode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;s&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;s&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="na"&gt;text-field&lt;/span&gt;
        &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Leave a note for the courier"&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;s&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="na"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&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;&lt;code&gt;shopify.cost.subtotalAmount&lt;/code&gt; updates automatically as the cart changes, and reading it doesn't cost you anything in terms of rate limits since it's a read, not a write. The &lt;code&gt;s-stack&lt;/code&gt;, &lt;code&gt;s-heading&lt;/code&gt;, &lt;code&gt;s-text&lt;/code&gt;, and &lt;code&gt;s-text-field&lt;/code&gt; elements are web components from Shopify's Polaris-based checkout component library, not raw HTML, so they inherit the store's checkout styling without extra CSS work on your part.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;shopify app dev&lt;/code&gt; at this point to preview the extension live on your dev store before writing a single line of persistence logic. Confirming the field renders and the subtotal updates is the cheapest bug you'll ever catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write Changes Back to the Order
&lt;/h2&gt;

&lt;p&gt;Reading data is half the job. The other half is persisting what the customer typed, which happens through &lt;code&gt;applyAttributeChange&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;setNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;shopify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;applyAttributeChange&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;updateAttribute&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deliveryInstructions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;value&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap this in for the inline &lt;code&gt;onChange&lt;/code&gt; in the component above. &lt;code&gt;applyAttributeChange&lt;/code&gt; returns a promise that resolves once Shopify has applied the change and the corresponding property has updated, and the attribute becomes visible on the order in the admin once it's placed.&lt;/p&gt;

&lt;p&gt;Two boundaries matter here. First, extensions that fire too many attribute or metafield changes in a short window get rate-limited for the rest of that buyer's session, so batch related writes with &lt;code&gt;Promise.all&lt;/code&gt; instead of firing one call per keystroke. Second, an attribute set this way lands on the order object, not inside the payment step; if you need something reflected in pricing or payment method availability, that's a Function's job, not this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the Extension Before It Touches Real Checkouts
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;shopify app dev&lt;/code&gt; gives you a live preview against a real dev store, and it reloads automatically as you edit. That covers manual testing. As of API version &lt;code&gt;2026-04&lt;/code&gt;, Shopify also ships &lt;code&gt;@shopify/ui-extensions-tester&lt;/code&gt; for writing actual unit tests against your extension code, which matters once the component grows past a single field and you want regression coverage without opening a browser every time.&lt;/p&gt;

&lt;p&gt;Two checkpoints are worth running before you touch a production store:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm the field appears in the correct slot using the checkout editor's preview, since block target placement is merchant-configurable and easy to get wrong during first setup.&lt;/li&gt;
&lt;li&gt;Place a real test order and check that the attribute shows up on the order detail page in the admin. If it's missing, the write silently failed or the promise was never awaited, not a rendering problem.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Deploy It, and Respect the 64 KB Ceiling
&lt;/h2&gt;

&lt;p&gt;Deployment is a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;shopify app deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI builds your extension bundle and uploads it to Shopify. There's a hard constraint worth planning around early: a compiled UI extension bundle can't exceed 64 KB, and Shopify enforces this at deploy time. A single text field extension won't come close, but adding a chart library or a large icon set will get you there faster than expected. &lt;a href="https://shopify.dev/docs/apps/build/app-extensions#analyzing-bundle-size" rel="noopener noreferrer"&gt;Analyze your bundle size&lt;/a&gt; before you're surprised by a failed deploy the day before launch.&lt;/p&gt;

&lt;p&gt;If your extension needs more than the default sandbox allows, such as calling your own backend or collecting SMS marketing consent, you declare that explicitly in the TOML under &lt;a href="https://shopify.dev/docs/apps/build/checkout/capabilities" rel="noopener noreferrer"&gt;&lt;code&gt;[extensions.capabilities]&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;network_access&lt;/code&gt;, &lt;code&gt;api_access&lt;/code&gt;, &lt;code&gt;collect_buyer_consent&lt;/code&gt;, &lt;code&gt;block_progress&lt;/code&gt;). Requesting a capability you don't end up using is a common reason review takes longer than expected, so keep the list matched to what the code actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Do Checkout Extensions Actually Break in Production?
&lt;/h2&gt;

&lt;p&gt;Two failure patterns show up more than any other.&lt;/p&gt;

&lt;p&gt;The first is a mismatch between an extension's declared &lt;code&gt;api_version&lt;/code&gt; and the version a linked extension expects, most commonly on payment-related targets. One developer on the &lt;a href="https://community.shopify.com/t/building-a-credit-card-payments-extension-with-ui-extensibility/401381" rel="noopener noreferrer"&gt;Shopify community forum&lt;/a&gt; hit a crash where checkout failed with a &lt;code&gt;TypeError&lt;/code&gt; before their React component even rendered, tied to a payment extension linked to a UI extension through &lt;code&gt;ui_extension_handle&lt;/code&gt;. The lesson generalizes: when two extensions are wired together, verify the target string and API version match exactly, and don't assume a typo in a config field will surface as a readable error. It often surfaces as a crash somewhere upstream of your own code.&lt;/p&gt;

&lt;p&gt;The second is the sandbox itself. The extension runs inside a Web Worker with no access to &lt;code&gt;window&lt;/code&gt; or the DOM, which means a third-party error-reporting tool that assumes a normal browser environment will silently fail to initialize unless you disable its default integrations and attach &lt;code&gt;error&lt;/code&gt; and &lt;code&gt;unhandledrejection&lt;/code&gt; listeners manually. If you're not seeing errors you know are happening, this is usually why.&lt;/p&gt;

&lt;p&gt;Neither failure mode is exotic. Both come from treating a sandboxed, upgrade-safe environment as if it were a regular webpage, which it deliberately isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build It Yourself or Bring in a Shopify Team?
&lt;/h2&gt;

&lt;p&gt;A single custom field like the one in this guide is a reasonable first project for a developer who's comfortable with React and hasn't touched Shopify's extension model before; budget a day or two including the review cycle. The calculation changes once you're coordinating multiple targets, metafields across international markets, post-purchase offers, or a Scripts-to-Functions migration against a hard deadline. That's less a coding problem than a project-management one, and it's the same reasoning Shopify gives store owners directly in its own upgrade guidance: build the extension yourself, or bring in a service partner to build it for you.&lt;/p&gt;

&lt;p&gt;For merchants in that second situation, &lt;a href="https://www.lucentinnovation.com/specialists/hire-shopify-developers" rel="noopener noreferrer"&gt;Lucent Innovation's Shopify development team&lt;/a&gt; handles exactly this kind of checkout migration and extension work as part of its Shopify Plus practice. Either path is legitimate. The point of this guide is that you now have enough to make that call with a working extension in front of you, not a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;You've got a checkout UI extension that reads live cart data and writes an attribute back to the order, scaffolded, tested, and deployed under the real constraints Shopify enforces at build time. The natural next step is a Shopify Function if your use case needs to touch pricing or shipping logic rather than just displaying something.&lt;/p&gt;

&lt;p&gt;Have you run into the Web Worker sandbox limitations while wiring up error tracking, or found a cleaner pattern for requesting capabilities without triggering a longer review? Drop it in the comments, it's the kind of detail that doesn't make it into the docs.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
