<?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: Top Edge AI</title>
    <description>The latest articles on DEV Community by Top Edge AI (@topedge_ai).</description>
    <link>https://dev.to/topedge_ai</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%2F4215498%2F7ed9cf7c-7369-41a7-9a49-ded1518af6f5.png</url>
      <title>DEV Community: Top Edge AI</title>
      <link>https://dev.to/topedge_ai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/topedge_ai"/>
    <language>en</language>
    <item>
      <title>WhatsApp + Shopify: 7 gotchas that cost me a week</title>
      <dc:creator>Top Edge AI</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:39:54 +0000</pubDate>
      <link>https://dev.to/topedge_ai/whatsapp-shopify-7-gotchas-that-cost-me-a-week-50lg</link>
      <guid>https://dev.to/topedge_ai/whatsapp-shopify-7-gotchas-that-cost-me-a-week-50lg</guid>
      <description>&lt;p&gt;I spent last month wiring WhatsApp order notifications into Shopify stores. The docs are decent. The gotchas aren’t documented anywhere.&lt;/p&gt;

&lt;p&gt;Here are the seven that actually cost me time.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Signature verification needs the raw body&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This one burned a full day.&lt;/p&gt;

&lt;p&gt;Meta signs every webhook with HMAC-SHA256 over the exact bytes it sent. If express.json() runs first, the raw body is gone — you’re hashing a re-serialised object, and the signature never matches.&lt;/p&gt;

&lt;p&gt;`// ❌ Signature will never verify&lt;br&gt;
app.use(express.json());&lt;br&gt;
app.post('/webhooks/whatsapp', verifySignature, handler);&lt;/p&gt;

&lt;p&gt;Mount the raw parser on that route before the global JSON parser:&lt;/p&gt;

&lt;p&gt;`import crypto from 'node:crypto';&lt;/p&gt;

&lt;p&gt;app.post(&lt;br&gt;
  '/webhooks/whatsapp',&lt;br&gt;
  express.raw({ type: 'application/json' }),&lt;br&gt;
  (req, res) =&amp;gt; {&lt;br&gt;
    const sig = req.get('x-hub-signature-256') || '';&lt;br&gt;
    const expected =&lt;br&gt;
      'sha256=' +&lt;br&gt;
      crypto&lt;br&gt;
        .createHmac('sha256', process.env.META_APP_SECRET)&lt;br&gt;
        .update(req.body)          // Buffer, not parsed JSON&lt;br&gt;
        .digest('hex');&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const a = Buffer.from(sig);
const b = Buffer.from(expected);
// timingSafeEqual throws on length mismatch — check first
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
  return res.sendStatus(401);
}

res.sendStatus(200);                      // ack fast
queue.add(JSON.parse(req.body.toString('utf8')));  // process after
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;app.use(express.json());  // everything else``&lt;/p&gt;

&lt;p&gt;Two things in there matter beyond the signature: ack before you process, and use timingSafeEqual, not ===.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Meta retries aggressively if you’re slow&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Meta expects a 200 fast. Take too long and it retries — so your “send order confirmation” handler fires three times and the customer gets three messages.&lt;/p&gt;

&lt;p&gt;Ack immediately, queue the work. Same for Shopify: it retries on non-2xx, so every handler needs to be idempotent. Dedupe on the event ID before you do anything with side effects.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Shopify uses a different HMAC encoding&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both sign webhooks. They don’t agree on the format.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;   Header  Encoding&lt;br&gt;
Meta    x-hub-signature-256 hex, prefixed sha256=&lt;br&gt;
Shopify x-shopify-hmac-sha256   base64, no prefix&lt;br&gt;
const digest = crypto&lt;br&gt;
  .createHmac('sha256', process.env.SHOPIFY_API_SECRET)&lt;br&gt;
  .update(req.body)&lt;br&gt;
  .digest('base64');   // not hex&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Copy-pasting your Meta verifier for Shopify fails silently. Ask me how I know.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The 24-hour window changes what you can send&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the one that breaks product assumptions, not just code.&lt;/p&gt;

&lt;p&gt;Customer messages you → 24-hour window opens → you can send free-form text&lt;br&gt;
Window closes → template only, and templates need pre-approval&lt;/p&gt;

&lt;p&gt;So “just reply to the customer” only works inside the window. Outside it, you need an approved template with variables filled in. Design your flows around this or you’ll ship something that works in testing (where you just messaged yourself) and fails in production.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Template category decides your bill&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every template is marketing, utility, or authentication. Meta classifies it, and it drives the price per conversation.&lt;/p&gt;

&lt;p&gt;In India the gap is large — marketing conversations cost several times what utility ones do. An order confirmation is utility. A discount blast is marketing. Mislabel a utility template as marketing and you pay more for the same message.&lt;/p&gt;

&lt;p&gt;Rejections are usually one of:&lt;/p&gt;

&lt;p&gt;Promotional language in a utility template&lt;br&gt;
Unclear variables — {{1}} with no sample value&lt;br&gt;
Variables at the very start or end of the body&lt;br&gt;
A placeholder you forgot to replace&lt;/p&gt;

&lt;p&gt;Submit with realistic sample values. “John” and “1042”, not “xxx”.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Phone numbers must be E.164, and India trips people up&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No +, no spaces, no dashes, country code always. 919876543210, not +91 98765 43210 and not 09876543210.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;const toE164 = (raw, cc = '91') =&amp;gt; {&lt;br&gt;
  const d = String(raw).replace(/\D/g, '');&lt;br&gt;
  if (d.length === 10) return cc + d;         // bare local&lt;br&gt;
  if (d.startsWith('0')) return cc + d.slice(1);  // leading zero&lt;br&gt;
  return d;&lt;br&gt;
};&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Normalise on the way in, not at send time. Otherwise the same customer ends up as three contacts.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Opt-in isn’t legal boilerplate — it’s your delivery rate&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Message people who didn’t opt in and they block you. Enough blocks and your number’s quality rating drops. Low quality means lower messaging limits, and Meta can restrict the number.&lt;/p&gt;

&lt;p&gt;Record consent per customer with its source and timestamp. Treat it as operational data, not a compliance checkbox — it’s the thing protecting your ability to send at all.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{ customerId, phone, consent: true, source: 'checkout_opt_in', at: '2026-10-29T…' }&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The order I’d build in&lt;/p&gt;

&lt;p&gt;Shopify OAuth, store the token&lt;br&gt;
Webhook endpoints with both HMAC verifiers, acking fast&lt;/p&gt;

&lt;p&gt;Opt-in capture before any sending&lt;/p&gt;

&lt;p&gt;One utility template (order confirmation), approved end to end&lt;br&gt;
Then flows&lt;/p&gt;

&lt;p&gt;Getting one template through approval before you build the flow engine saves a lot of rework. &lt;/p&gt;

&lt;p&gt;Approval is the step with the unpredictable timeline.&lt;/p&gt;

&lt;p&gt;I work on &lt;a href="https://topedgeai.com/" rel="noopener noreferrer"&gt;TopEdge&lt;/a&gt;, which does this for Shopify stores in India &lt;/p&gt;

&lt;p&gt;we publish Meta’s India conversation rates if you want the current numbers for the template-category math in #5. &lt;/p&gt;

&lt;p&gt;Everything above applies whether you build it or buy it.&lt;/p&gt;

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