<?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: Checkout Flow Digital</title>
    <description>The latest articles on DEV Community by Checkout Flow Digital (@checkoutflowdigital).</description>
    <link>https://dev.to/checkoutflowdigital</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%2F4160846%2Faa183648-6c86-4ae1-baff-30ed305ede7d.png</url>
      <title>DEV Community: Checkout Flow Digital</title>
      <link>https://dev.to/checkoutflowdigital</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/checkoutflowdigital"/>
    <language>en</language>
    <item>
      <title>Webhook vs API: Choosing the Right Integration Pattern</title>
      <dc:creator>Checkout Flow Digital</dc:creator>
      <pubDate>Sun, 04 Oct 2026 06:01:24 +0000</pubDate>
      <link>https://dev.to/checkoutflowdigital/webhook-vs-api-choosing-the-right-integration-pattern-5h57</link>
      <guid>https://dev.to/checkoutflowdigital/webhook-vs-api-choosing-the-right-integration-pattern-5h57</guid>
      <description>&lt;p&gt;"Should this integration use the API or a webhook?" comes up at the start of almost every integration project, and it is slightly the wrong question. Webhooks and APIs are not alternatives. They cover two different halves of the same problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;API&lt;/strong&gt; is how &lt;em&gt;your&lt;/em&gt; system requests data from another system, or instructs it to do something.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;webhook&lt;/strong&gt; is how the &lt;em&gt;other&lt;/em&gt; system lets yours know that something just happened.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Integrations that survive production usually use both. This post covers the difference, when each one fits, and the handful of patterns (signature verification, idempotency, retries, reconciliation) that make the combination reliable. The examples come from e-commerce, Shopify in particular, but the same patterns apply to payment providers, CRMs and most SaaS platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Push vs pull
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PULL — the API                          PUSH — the webhook

your service                            platform
    │  GET orders updated since 10:00       │  POST /webhooks/orders  (event)
    ▼                                       ▼
platform                                your endpoint
    │  200 + data                           │  200 OK  (acknowledgement)
    ▼                                       ▼
your service                            platform

You decide when to ask.                 The platform decides when to tell you.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;With &lt;strong&gt;pull&lt;/strong&gt;, you control timing, volume and exactly what you fetch. The cost is latency (you only learn about a change when you next ask) and wasted calls (most polls return nothing new).&lt;/li&gt;
&lt;li&gt;With &lt;strong&gt;push&lt;/strong&gt;, you hear about an event seconds after it happens, without polling. The cost is a public endpoint that has to be fast, secure, and tolerant of duplicates and gaps.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Webhook vs API at a glance
&lt;/h2&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;API (pull)&lt;/th&gt;
&lt;th&gt;Webhook (push)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who initiates&lt;/td&gt;
&lt;td&gt;Your system&lt;/td&gt;
&lt;td&gt;The other platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When data arrives&lt;/td&gt;
&lt;td&gt;Whenever you ask: on demand or on a timer&lt;/td&gt;
&lt;td&gt;Usually within seconds of the event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What it can do&lt;/td&gt;
&lt;td&gt;Read &lt;strong&gt;and&lt;/strong&gt; write&lt;/td&gt;
&lt;td&gt;Notify only; you still call the API to act&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good at&lt;/td&gt;
&lt;td&gt;Creating and updating records, searches, backfills, catching up after an outage&lt;/td&gt;
&lt;td&gt;Reacting to events: payment captured, order created, stock changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical failure&lt;/td&gt;
&lt;td&gt;Polling so often you hit rate limits, or so rarely the data goes stale&lt;/td&gt;
&lt;td&gt;Deliveries that are missed, late, duplicated, out of order or forged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What you have to build&lt;/td&gt;
&lt;td&gt;Authentication, pagination, rate-limit handling, version upgrades&lt;/td&gt;
&lt;td&gt;A public HTTPS endpoint, signature verification, de-duplication, a queue&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A webhook is sometimes called a "reverse API": same building blocks (HTTP, JSON), opposite direction. It rarely replaces the API, though. A webhook payload tells you &lt;em&gt;that&lt;/em&gt; something happened; reading the full record or changing anything usually still goes through the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real flow: paid order → accounting software
&lt;/h2&gt;

&lt;p&gt;Say every paid Shopify order must show up as a sale in an accounting tool. A design that holds up in production looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REAL-TIME PATH
Shopify ──(webhook: order paid)──▶ your endpoint
  1. verify the signature on the raw body     → 401 if invalid
  2. record the delivery id; seen it before?  → 200, do nothing
  3. put a job on a queue                     → 200 immediately
                     │
                     ▼
worker
  4. read the full order through the Admin API (if the payload isn't enough)
  5. sale already created for this order? skip · otherwise create it
     through the accounting API
  6. transient error: retry with backoff · permanent error: stop and alert

SAFETY NET (hourly or daily)
scheduler ──▶ Admin API: "orders updated since &amp;lt;last run minus an overlap&amp;gt;"
  7. hand each order to the same worker: step 5's order-level check skips
     orders a webhook already synced (step 2 can't: an order fetched from
     the API carries no webhook delivery id)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Speed comes from the webhook; control and recovery come from the API. Integrations tend to break exactly where a team relied on only one of them: polling-only setups are slow and eat the rate limit, and webhook-only setups quietly lose data the first time the endpoint is down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks: what you have to get right
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Verify every delivery before trusting it
&lt;/h3&gt;

&lt;p&gt;Your endpoint is a public URL, so anyone can POST to it. Shopify signs each HTTPS delivery: the &lt;code&gt;X-Shopify-Hmac-Sha256&lt;/code&gt; header holds a base64-encoded HMAC-SHA256 of the &lt;strong&gt;raw request body&lt;/strong&gt;, keyed with your app's client secret. Recompute it and reject anything that doesn't match.&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;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;node: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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;hmacHeader&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&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;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;expected&lt;/span&gt; &lt;span class="o"&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;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;secret&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="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="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt; &lt;span class="o"&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="c1"&gt;// timingSafeEqual throws when lengths differ, so check that first&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;received&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;Two mistakes cause most "my HMAC never matches" bugs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hashing a parsed and re-serialized body.&lt;/strong&gt; If your framework parses JSON before your handler runs, whitespace and key order change and the signature will never match. Capture the raw bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comparing with &lt;code&gt;===&lt;/code&gt;.&lt;/strong&gt; Use a constant-time comparison (&lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; in Node, &lt;code&gt;hmac.compare_digest&lt;/code&gt; in Python).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most payment providers sign webhooks the same way (an HMAC over the raw body, sent in a header). What changes is the header name, sometimes hex instead of base64, and sometimes a timestamp included in the signed content to limit replays.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Acknowledge fast, process later
&lt;/h3&gt;

&lt;p&gt;Senders give you a short window. Shopify, for example, documents a one-second connection timeout and five seconds for the whole request. It treats any response outside the 2xx range (redirects included) as a failure and retries failed deliveries up to 8 times over 4 hours with exponential backoff. If failures persist, the subscription can be removed.&lt;/p&gt;

&lt;p&gt;So the request handler should do the minimum (verify, record, enqueue) and return 2xx. Calls to your ERP, accounting tool or email provider belong in a worker, where a slow third party can't make the sender give up on you.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Expect duplicates and disorder: make processing idempotent
&lt;/h3&gt;

&lt;p&gt;Webhooks are delivered &lt;em&gt;at least once&lt;/em&gt;. The same event can arrive twice (a timeout on your side, then a retry on theirs), and events can show up in a different sequence than they occurred.&lt;/p&gt;

&lt;p&gt;The usual fix is an idempotency ledger: claim a key before doing any work, and skip the delivery if the key is already taken.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;on delivery:
  key = delivery id
        (fallback: topic + resource id + updated_at)
  if not ledger.claim(key):   # atomic insert against a unique constraint
      return 200              # duplicate: acknowledge, do nothing
  enqueue(job)                # if this fails: release the claim, return 5xx
  return 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Shopify, the documented header for de-duplicating deliveries is &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt;. &lt;code&gt;X-Shopify-Event-Id&lt;/code&gt; is shared by deliveries to different subscriptions that come from the same merchant action, which makes it useful for correlation.&lt;/p&gt;

&lt;p&gt;A few details matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The ledger must be shared&lt;/strong&gt; by every instance that handles webhooks: a table with a unique constraint, or Redis &lt;code&gt;SET key value NX EX &amp;lt;ttl&amp;gt;&lt;/code&gt;. An in-memory set only works on a single process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep delivery keys long enough&lt;/strong&gt; to cover the sender's retry window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ordering:&lt;/strong&gt; don't assume it. When order matters, compare the resource's &lt;code&gt;updated_at&lt;/code&gt; with what you last stored and ignore stale updates, or re-read the current state from the API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business-level idempotency:&lt;/strong&gt; the delivery ledger only absorbs repeated deliveries. The step that creates the invoice needs its own guard at the order level: check whether a sale already exists for this order (in your own records, or by external reference in the target system), or pass an idempotency key if the target API supports one. Make that check atomic, for example with a unique constraint on the order id in your sync records, so two workers can't both pass it at the same time. That order-level check is also what makes reconciliation safe (section 5).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Retry with backoff, and know when to stop
&lt;/h3&gt;

&lt;p&gt;Inside the worker, the downstream API will fail from time to time. Treat the two kinds of failure differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transient&lt;/strong&gt; (network error, timeout, 429, 5xx): retry with exponential backoff and jitter, and honour &lt;code&gt;Retry-After&lt;/code&gt; when the API sends it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permanent&lt;/strong&gt; (400 validation error, 404, a business rule): stop, record the failure, alert a human. Retrying will not help.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// "Full jitter": a random delay between 0 and the exponential cap&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delayMs&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;random&lt;/span&gt;&lt;span class="p"&gt;()&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Jitter matters more than it seems. After an outage, thousands of jobs retrying on the same schedule hit the recovering API at the same moment. Jobs that fail for good should land somewhere a person actually looks.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Never rely on webhooks alone: reconcile on a schedule
&lt;/h3&gt;

&lt;p&gt;Endpoints go down during deploys, certificates expire, a bug returns 500 for an hour, a retry window runs out. Sooner or later a webhook will be missed.&lt;/p&gt;

&lt;p&gt;Reconciliation is a scheduled job, hourly or daily, that asks the API what changed since the last run (with some overlap) and hands every result to the &lt;strong&gt;same&lt;/strong&gt; worker the webhooks feed. With Shopify, that means a GraphQL Admin API query on orders filtered by &lt;code&gt;updated_at&lt;/code&gt;, sorted by &lt;code&gt;UPDATED_AT&lt;/code&gt; and paginated with cursors.&lt;/p&gt;

&lt;p&gt;Don't count on the webhook delivery ledger to prevent duplicates here. An order fetched from the API carries no webhook delivery id, so a ledger keyed on delivery ids cannot tell that a webhook already handled it. What prevents a second sale is the order-level check inside the worker: has a sale already been created for this order? Orders a webhook already synced stop there, and the missed ones get processed.&lt;/p&gt;

&lt;p&gt;This is the piece that turns "usually works" into "trustworthy".&lt;/p&gt;

&lt;h2&gt;
  
  
  APIs: what you have to get right
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Stay inside the rate limits
&lt;/h3&gt;

&lt;p&gt;Every API meters its usage. Shopify's GraphQL Admin API, for instance, charges a calculated cost per query against a bucket that refills continuously, and the refill rate depends on the store's plan. Request only the fields you need, paginate, and back off when you're throttled (an HTTP 429, or a &lt;code&gt;THROTTLED&lt;/code&gt; error in a GraphQL response) instead of hammering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep credentials on the server
&lt;/h3&gt;

&lt;p&gt;Access tokens and API secrets belong on a server or in a secret manager. Never put them in a storefront theme, in front-end JavaScript or in a repository. Ask for the minimum scopes: a reconciliation job that reads orders needs &lt;code&gt;read_orders&lt;/code&gt;, not write access to everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pin versions and plan upgrades
&lt;/h3&gt;

&lt;p&gt;APIs are versioned, and so are webhook payloads (Shopify sends the version in an &lt;code&gt;X-Shopify-API-Version&lt;/code&gt; header). Pin a version explicitly, keep an eye on deprecation announcements, and run your tests against a new version before switching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes and what handles them
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What goes wrong&lt;/th&gt;
&lt;th&gt;What protects you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Forged request to your endpoint&lt;/td&gt;
&lt;td&gt;HMAC verification on the raw body, then 401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same event delivered twice&lt;/td&gt;
&lt;td&gt;Idempotency ledger keyed on the delivery id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Events arrive out of order&lt;/td&gt;
&lt;td&gt;Compare &lt;code&gt;updated_at&lt;/code&gt;, or re-read current state through the API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow handler, so the sender times out and retries&lt;/td&gt;
&lt;td&gt;Acknowledge fast, process in a queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downstream API flaky or rate-limited&lt;/td&gt;
&lt;td&gt;Retries with exponential backoff, jitter and &lt;code&gt;Retry-After&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downstream rejects the data&lt;/td&gt;
&lt;td&gt;Permanent-error path: stop, log, alert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint down for longer than the retry window&lt;/td&gt;
&lt;td&gt;Scheduled reconciliation through the API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker crashes after you already returned 2xx&lt;/td&gt;
&lt;td&gt;A durable queue (database table, SQS, Cloud Tasks…), not process memory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Doing the real work inside the webhook request, then timing out.&lt;/li&gt;
&lt;li&gt;Verifying the signature against re-serialized JSON instead of the raw body.&lt;/li&gt;
&lt;li&gt;De-duplicating with an in-memory set on a multi-instance deployment.&lt;/li&gt;
&lt;li&gt;Returning 2xx while the job only lives in process memory.&lt;/li&gt;
&lt;li&gt;Polling every few seconds "to be safe" and burning through the rate limit.&lt;/li&gt;
&lt;li&gt;Webhook-only integrations with no reconciliation job.&lt;/li&gt;
&lt;li&gt;Shipping an Admin API token in theme or front-end code.&lt;/li&gt;
&lt;li&gt;Retrying a 400 forever.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Decision checklist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Which one?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Something just happened and you must react in seconds&lt;/strong&gt; (payment confirmed, order created): a &lt;strong&gt;webhook&lt;/strong&gt; to hear about it, the &lt;strong&gt;API&lt;/strong&gt; to act on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're writing to another system&lt;/strong&gt; (creating or updating records): &lt;strong&gt;API&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch work&lt;/strong&gt; such as a nightly export, a report or a backfill: &lt;strong&gt;scheduled API calls&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No webhook exists&lt;/strong&gt; for the event you care about: &lt;strong&gt;poll the API&lt;/strong&gt; on a sensible interval, inside the rate limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Money or inventory is at stake&lt;/strong&gt;: &lt;strong&gt;both&lt;/strong&gt;, with reconciliation on top.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Before a webhook endpoint goes live:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Signature verified on the raw body with a constant-time comparison&lt;/li&gt;
&lt;li&gt;2xx returned within a couple of seconds; slow work handed to a durable queue&lt;/li&gt;
&lt;li&gt;Idempotency ledger shared by every instance&lt;/li&gt;
&lt;li&gt;Retries with backoff and jitter; permanent errors reach a human&lt;/li&gt;
&lt;li&gt;Scheduled reconciliation that overlaps the previous run and feeds the same worker&lt;/li&gt;
&lt;li&gt;An atomic, order-level "sale already created?" check in that worker (for example, a unique constraint on the order id), so webhooks and reconciliation can't both create the sale&lt;/li&gt;
&lt;li&gt;Secrets in the environment or a secret manager; minimal API scopes&lt;/li&gt;
&lt;li&gt;One log line per delivery: accepted, duplicate, rejected or failed&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Is polling an API bad practice?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Polling on a sensible schedule that stays under the rate limits is how you catch up after gaps, and it's the only option when a platform has no webhooks. Where it falls short is reacting in near real time: that's the webhook's job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I skip the queue at low volume?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your handler only writes to your own database and finishes well inside the sender's timeout, you can start without one. Once a third-party API call sits in the request path, though, your acknowledgement depends on someone else's latency, and that is when duplicates and dropped events start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Companion code
&lt;/h2&gt;

&lt;p&gt;If you'd like to see these patterns as runnable code, there is a companion repository: &lt;strong&gt;&lt;a href="https://github.com/checkoutflowdigital/shopify-webhook-patterns" rel="noopener noreferrer"&gt;checkoutflowdigital/shopify-webhook-patterns&lt;/a&gt;&lt;/strong&gt;. It holds small, dependency-free examples in Node.js (18+) and Python (3.10+):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HMAC signature verification (raw body, constant-time comparison)&lt;/li&gt;
&lt;li&gt;an idempotency ledger keyed on &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt;, with a run-once helper&lt;/li&gt;
&lt;li&gt;retries with full-jitter exponential backoff and a permanent-error path&lt;/li&gt;
&lt;li&gt;a scheduled reconciliation job against the Shopify GraphQL Admin API (pagination, throttling), in Node.js only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first three exist in both languages and come with tests. The reconciliation job is a Node.js sketch that leaves the order-level "already synced?" check to the handler you pass in.&lt;/p&gt;

&lt;p&gt;It is reference code to read and adapt, not an official Shopify library and not a drop-in package. Swap the in-memory store and queue for your own database and queue before taking the ideas to production.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is adapted from &lt;a href="https://checkoutflowdigital.com/blogs/guides/webhook-vs-api" rel="noopener noreferrer"&gt;Webhook vs API: what's the difference, with e-commerce examples&lt;/a&gt;, first published by &lt;a href="https://checkoutflowdigital.com" rel="noopener noreferrer"&gt;Checkout Flow Digital — Commerce &amp;amp; Payment Integration&lt;/a&gt;.&lt;/em&gt;&lt;a href="https://dev.tourl"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>shopify</category>
      <category>webhooks</category>
    </item>
  </channel>
</rss>
