<?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: Andras Palinkas</title>
    <description>The latest articles on DEV Community by Andras Palinkas (@andras_palinkas).</description>
    <link>https://dev.to/andras_palinkas</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%2F4135898%2Fd95f87ec-2713-4e61-a3bf-9f296c53850c.png</url>
      <title>DEV Community: Andras Palinkas</title>
      <link>https://dev.to/andras_palinkas</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andras_palinkas"/>
    <language>en</language>
    <item>
      <title>There is no 'abandoned' event: a developer's map of Shopify's abandoned checkout API and webhooks</title>
      <dc:creator>Andras Palinkas</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:57:05 +0000</pubDate>
      <link>https://dev.to/andras_palinkas/there-is-no-abandoned-event-a-developers-map-of-shopifys-abandoned-checkout-api-and-webhooks-25fh</link>
      <guid>https://dev.to/andras_palinkas/there-is-no-abandoned-event-a-developers-map-of-shopifys-abandoned-checkout-api-and-webhooks-25fh</guid>
      <description>&lt;p&gt;The first thing most people look for when they start building on Shopify's abandoned checkouts doesn't exist: there is no webhook that fires when a checkout &lt;em&gt;becomes&lt;/em&gt; abandoned. That one fact shapes the whole design, so here's the map I wish I'd had on day one — what Shopify actually gives you, what it doesn't, and the two things we learned the hard way while building a cart-recovery app on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Abandoned" is a state, not an event
&lt;/h2&gt;

&lt;p&gt;Shopify's &lt;a href="https://shopify.dev/docs/api/admin-rest/latest/resources/abandoned-checkouts" rel="noopener noreferrer"&gt;Abandoned Checkouts documentation&lt;/a&gt; defines it in one sentence: &lt;em&gt;"A checkout is considered abandoned after the customer has added contact information, but before the customer has completed their purchase."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nothing fires at the moment of abandonment, because there is no such moment. In practice you subscribe to checkout events, watch for completion, and apply your own delay before treating a checkout as abandoned. The delay is yours to choose — Shopify leaves it entirely to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The webhooks
&lt;/h2&gt;

&lt;p&gt;From the &lt;a href="https://shopify.dev/docs/api/admin-graphql/latest/enums/WebhookSubscriptionTopic" rel="noopener noreferrer"&gt;webhook topic list&lt;/a&gt;, the relevant topics:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;checkouts/create&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A checkout started. In our tests it fired within seconds of the shopper typing an email address.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;checkouts/update&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Something changed — address, shipping, payment step. Carries &lt;code&gt;completed_at&lt;/code&gt; once the order is placed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;checkouts/delete&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The checkout was removed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orders/create&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The reliable completion signal: match it to the checkout and cancel any pending reminder.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;carts/create&lt;/code&gt;, &lt;code&gt;carts/update&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The pre-checkout cart. No contact information, so useful for analytics, not for reaching anyone.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The checkout payload has what a recovery flow needs: an id and &lt;code&gt;token&lt;/code&gt;, the &lt;code&gt;cart_token&lt;/code&gt; linking it to the earlier cart, &lt;code&gt;email&lt;/code&gt; (once entered), &lt;code&gt;total_price&lt;/code&gt; and &lt;code&gt;currency&lt;/code&gt;, &lt;code&gt;line_items&lt;/code&gt;, &lt;code&gt;completed_at&lt;/code&gt;, and &lt;code&gt;abandoned_checkout_url&lt;/code&gt; — the link that drops the shopper back into their checkout with everything still in it.&lt;/p&gt;

&lt;p&gt;Subscriptions are declared in the app config. This is the shape we use:&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="nn"&gt;[[webhooks.subscriptions]]&lt;/span&gt;
&lt;span class="py"&gt;uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/webhooks/checkouts/update"&lt;/span&gt;
&lt;span class="py"&gt;topics&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s"&gt;"checkouts/create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"checkouts/update"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nn"&gt;[[webhooks.subscriptions]]&lt;/span&gt;
&lt;span class="py"&gt;uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/webhooks/orders/create"&lt;/span&gt;
&lt;span class="py"&gt;topics&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s"&gt;"orders/create"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The two things we learned the hard way
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Webhooks arrive more than once, and out of order.&lt;/strong&gt; The handler has to be idempotent — upsert by &lt;code&gt;token&lt;/code&gt;, and prefer &lt;code&gt;completed_at&lt;/code&gt; over arrival order when deciding whether a checkout is still open. A checkout can look abandoned in one event and completed in the next, delivered in the opposite sequence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Answer fast, then do the work.&lt;/strong&gt; Shopify expects a 2xx quickly; slow handlers get retried and, eventually, the subscription is dropped. Acknowledge first, process after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The listing endpoints are for reconciliation, not timeliness
&lt;/h2&gt;

&lt;p&gt;The GraphQL &lt;a href="https://shopify.dev/docs/api/admin-graphql/latest/queries/abandonedCheckouts" rel="noopener noreferrer"&gt;&lt;code&gt;abandonedCheckouts&lt;/code&gt;&lt;/a&gt; query lists abandoned checkouts (the docs say it needs the &lt;code&gt;read_orders&lt;/code&gt; scope). It's the right tool for backfilling on install and for reconciling your own records. It is not the timely signal: in our testing the listing lagged the webhook stream by hours while the webhooks were immediate.&lt;/p&gt;

&lt;p&gt;The REST resource (&lt;code&gt;GET /admin/api/{version}/checkouts.json&lt;/code&gt;) still works, but Shopify marked the REST Admin API as legacy as of October 1, 2024 and requires new public apps to use GraphQL from April 1, 2025. Start new work on GraphQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protected customer data
&lt;/h2&gt;

&lt;p&gt;Abandoned checkouts are full of personal data, and Shopify treats it as &lt;a href="https://shopify.dev/docs/apps/launch/protected-customer-data" rel="noopener noreferrer"&gt;protected customer data&lt;/a&gt;. The shopper's name, address, phone and email are protected &lt;em&gt;fields&lt;/em&gt; that have to be requested individually, with data-protection requirements attached (encryption, retention limits, a review before the App Store listing is approved). The practical consequence: if a feature only needs counts, totals or line items, don't request the fields — the approval is lighter and there's less to protect. An app that actually contacts the shopper can't avoid it; we store email addresses encrypted at rest for exactly this reason.&lt;/p&gt;

&lt;p&gt;Two more things every app in this space needs: a retention rule of its own (Shopify deletes abandoned checkouts older than three months from the admin, so your copy shouldn't outlive that by much), and handlers for the three mandatory compliance topics — &lt;code&gt;customers/data_request&lt;/code&gt;, &lt;code&gt;customers/redact&lt;/code&gt;, &lt;code&gt;shop/redact&lt;/code&gt; — which the App Store checks for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Subscribe to &lt;code&gt;checkouts/create&lt;/code&gt;, &lt;code&gt;checkouts/update&lt;/code&gt; and &lt;code&gt;orders/create&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Upsert the checkout by token on each event; mark it complete on &lt;code&gt;completed_at&lt;/code&gt; or a matching order.&lt;/li&gt;
&lt;li&gt;After your chosen delay, if it's still open, send the reminder using &lt;code&gt;abandoned_checkout_url&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Backfill and reconcile with &lt;code&gt;abandonedCheckouts&lt;/code&gt;; expire your records on your own schedule.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3's delay is the interesting design decision — and the one thing Shopify leaves entirely to you. There's a related gotcha worth knowing before you gate anything on consent: Shopify only records the checkout marketing checkbox when the shopper proceeds to Pay now, so most abandoned checkouts carry no recorded consent. I wrote that one up &lt;a href="https://dev.to/andras_palinkas/til-shopify-only-saves-the-checkout-marketing-checkbox-when-the-shopper-proceeds-pay-now-on-6bp"&gt;here&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The full version of this map, with the merchant-facing side, lives on the &lt;a href="https://cartback.shop/blog/shopify-abandoned-checkout-api-webhooks" rel="noopener noreferrer"&gt;Cartback blog&lt;/a&gt;. Cartback is a cart-recovery app for Shopify built on exactly these pieces.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>webhooks</category>
      <category>api</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>TIL: Shopify only saves the checkout marketing checkbox when the shopper proceeds (Pay now, on one-page checkout)</title>
      <dc:creator>Andras Palinkas</dc:creator>
      <pubDate>Mon, 21 Sep 2026 14:07:56 +0000</pubDate>
      <link>https://dev.to/andras_palinkas/til-shopify-only-saves-the-checkout-marketing-checkbox-when-the-shopper-proceeds-pay-now-on-6bp</link>
      <guid>https://dev.to/andras_palinkas/til-shopify-only-saves-the-checkout-marketing-checkbox-when-the-shopper-proceeds-pay-now-on-6bp</guid>
      <description>&lt;p&gt;While building a cart-recovery app for Shopify, I hit a bug report I couldn't reproduce as a bug: a shopper ticks "Email me with news and offers" at checkout, abandons — and the abandoned checkout webhook arrives with &lt;code&gt;buyer_accepts_marketing: false&lt;/code&gt;. Every time, in our tests.&lt;/p&gt;

&lt;p&gt;It turned out not to be a bug. It's documented — just easy to miss, and it has real consequences for anyone building on the checkout APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The documented behavior
&lt;/h2&gt;

&lt;p&gt;Shopify's Help Center page on &lt;a href="https://help.shopify.com/en/manual/promoting-marketing/create-marketing/customer-contact-information" rel="noopener noreferrer"&gt;collecting customer contact information&lt;/a&gt; says consent is captured when the customer selects the marketing checkbox &lt;strong&gt;and proceeds&lt;/strong&gt; — by selecting &lt;strong&gt;Pay now&lt;/strong&gt; on one-page checkout, or &lt;strong&gt;Continue to shipping&lt;/strong&gt; on three-page checkout.&lt;/p&gt;

&lt;p&gt;And since &lt;a href="https://changelog.shopify.com/posts/one-page-checkout-is-now-the-default-shopify-checkout" rel="noopener noreferrer"&gt;one-page checkout became the default in 2023&lt;/a&gt;, most stores run the single-page layout — where the recording step is &lt;strong&gt;Pay now&lt;/strong&gt; itself.&lt;/p&gt;

&lt;p&gt;Put those together: an abandoned checkout is one where the purchase was never completed — in the common case, the shopper never pressed Pay now. So on a one-page store, an abandoned checkout from a first-time shopper will essentially never carry recorded consent, no matter what the checkbox showed on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I saw in testing
&lt;/h2&gt;

&lt;p&gt;On a development store (July 2026), with a fresh email address:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;checkouts/create&lt;/code&gt; fired within seconds of typing the email — with &lt;code&gt;buyer_accepts_marketing: false&lt;/code&gt; and &lt;strong&gt;no customer object at all&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;ticking the checkbox afterwards produced no consent change in any subsequent &lt;code&gt;checkouts/update&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the REST &lt;code&gt;/admin/checkouts.json&lt;/code&gt; listing lagged the webhooks by hours; the webhook stream was the timely signal (and Shopify has marked the REST Admin API as legacy since October 2024 — the GraphQL &lt;a href="https://shopify.dev/docs/api/admin-graphql/latest/queries/abandonedCheckouts" rel="noopener noreferrer"&gt;&lt;code&gt;abandonedCheckouts&lt;/code&gt;&lt;/a&gt; query is the current read path)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Shopify's &lt;a href="https://shopify.dev/docs/api/admin-rest/latest/resources/abandoned-checkouts" rel="noopener noreferrer"&gt;Abandoned Checkouts docs&lt;/a&gt; define the resource as: &lt;em&gt;"A checkout is considered abandoned after the customer has added contact information, but before the customer has completed their purchase."&lt;/em&gt; Nothing about consent timing — which is what makes this easy to trip over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Shopify's own abandoned checkout automation &lt;a href="https://help.shopify.com/en/manual/promoting-marketing/create-marketing/shopify-messaging/marketing-automations/create" rel="noopener noreferrer"&gt;sends to subscribed customers only by default&lt;/a&gt; — one reason merchants often wonder why so few automatic recovery emails go out. The email's To: field can be switched to All customers, but that's a technical setting, not a legal green light: whether a recovery email to a non-subscribed shopper needs prior consent depends on jurisdiction (the EU is stricter than the US, for example).&lt;/li&gt;
&lt;li&gt;If your app gates recovery emails on recorded consent, expect to skip most abandoned checkouts from new shoppers — not because shoppers didn't tick the box, but because Shopify never persisted it.&lt;/li&gt;
&lt;li&gt;Returning customers who subscribed during an earlier completed purchase &lt;em&gt;do&lt;/em&gt; carry consent, so their checkouts behave "normally" — which makes the behavior look intermittent if you don't know the rule.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Practical takeaways for builders
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Read consent from the customer-level &lt;code&gt;email_marketing_consent&lt;/code&gt; as well as the checkout's &lt;code&gt;buyer_accepts_marketing&lt;/code&gt; — returning subscribers show up there. (The checkout webhook includes a &lt;code&gt;customer&lt;/code&gt; object only when Shopify recognizes the shopper.)&lt;/li&gt;
&lt;li&gt;Don't treat a false flag as "shopper declined" — on one-page checkout it mostly means "shopper never reached the recording step".&lt;/li&gt;
&lt;li&gt;If your product's behavior depends on this (ours did), make the policy an explicit merchant setting rather than a silent gate.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://cartback.shop" rel="noopener noreferrer"&gt;Cartback&lt;/a&gt;, a cart-recovery app for Shopify — this is one of a few documented-but-easy-to-miss behaviors we've written up &lt;a href="https://cartback.shop/blog" rel="noopener noreferrer"&gt;on the blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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