# Send Shopify order updates on WhatsApp

:::summary Quick answer
To send Shopify order updates on WhatsApp, subscribe your app to the `orders/create` and `orders/fulfilled` webhooks, verify each delivery's `X-Shopify-Hmac-Sha256` header over the raw body, and call `POST /messages` with an approved WhatsApp template. Answer Shopify with `200` only after Flownally accepts the message, so Shopify retries the failures. An `Idempotency-Key` built from the order ID makes those retries safe.

**Verify the signature, send once, then answer `200`.**
:::

This guide is the build companion to [WhatsApp order updates for ecommerce](https://www.flownally.com/blog/whatsapp-order-updates-ecommerce), which covers which updates are worth sending.

## What you need

- **A WhatsApp inbox.** Copy its `id` from [List inboxes](/api/reference/inboxes#get-inboxes).
- **Two approved templates.** `order_confirmed` takes the first name and order name as body placeholders, and `order_shipped` takes the first name, order name and tracking link. Find names and languages with [List WhatsApp templates](/api/reference/inboxes#list-whats-app-templates).
- **A service account API key.** Store it outside the code, as described in [Authentication](/api/authentication).
- **A Shopify app.** It needs the `read_orders` access scope, and you need its client secret.
- **A web server.** For Node.js 18 or later, install `express` and `libphonenumber-js`. For Python, install `flask`, `requests` and `phonenumbers`. For PHP, install `guzzlehttp/guzzle` and `giggsey/libphonenumber-for-php`.

:::callout Request access to customer phone numbers
Shopify treats phone numbers as protected customer data. Without approved access to the phone field, Shopify redacts it from webhook payloads, and there's no number to message. See Shopify's [protected customer data](https://shopify.dev/docs/apps/launch/protected-customer-data) requirements.
:::

Order updates usually reach customers outside the 24-hour [reply window](/api/reply-window), so they need approved templates. Send a template's `name` and `language` exactly as listed, and only for templates with `status` `APPROVED`.

## Which Shopify webhooks should I subscribe to?

Each topic should match a moment the customer cares about: the order is confirmed, and then it's on its way.

**Order placed → `orders/create` → Confirmation template**

**Order fulfilled → `orders/fulfilled` → Shipping template**

`orders/fulfilled` arrives once every item in the order is fulfilled. Add `orders/partially_fulfilled` if you ship orders in parts.

Point the subscriptions at your endpoint, for example `https://shop-api.example.com/webhooks/shopify`. Shopify's [webhook guide](https://shopify.dev/docs/apps/build/webhooks/subscribe) covers subscribing in the app configuration or through the Admin API. Each delivery names its topic in the `X-Shopify-Topic` header.

## How do I verify the X-Shopify-Hmac-Sha256 header?

Your endpoint is public, so anyone can post an order to it. **Checking the signature makes sure only Shopify can make you message a customer.**

Shopify signs each delivery with HMAC-SHA256 over the raw request body, using your app's client secret as the key. It sends the base64-encoded result in `X-Shopify-Hmac-Sha256`.

:::code
```js
// shopify-hmac.js
import crypto from "node:crypto";

export function isFromShopify(rawBody, hmacHeader) {
  const expected = crypto
    .createHmac("sha256", process.env.SHOPIFY_CLIENT_SECRET)
    .update(rawBody)
    .digest();
  const received = Buffer.from(hmacHeader ?? "", "base64");

  // timingSafeEqual throws on buffers of different length.
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
```

```python
# shopify_hmac.py
import base64
import hashlib
import hmac
import os


def is_from_shopify(raw_body, hmac_header):
    digest = hmac.new(os.environ["SHOPIFY_CLIENT_SECRET"].encode(), raw_body, hashlib.sha256).digest()
    return hmac.compare_digest(base64.b64encode(digest), (hmac_header or "").encode())
```

```php
<?php
// shopify-hmac.php

function isFromShopify(string $rawBody, ?string $hmacHeader): bool
{
    $digest = hash_hmac('sha256', $rawBody, getenv('SHOPIFY_CLIENT_SECRET'), true);
    return hash_equals(base64_encode($digest), $hmacHeader ?? '');
}
```
:::

:::callout Use the raw body
Compute the digest over the exact bytes you received. A body that was parsed and serialized again won't match the signature.
:::

## Receive Shopify order webhooks

**Shopify delivers the webhook → Signature checked → Template sent → `200` to Shopify**

The endpoint reads the raw body, rejects unsigned requests, and sends a template for each order topic. It reads `FLOWNALLY_API_TOKEN`, `FLOWNALLY_INBOX_ID` and `SHOPIFY_CLIENT_SECRET` from the environment.

:::code
```js
// server.js
import express from "express";
import { parsePhoneNumberFromString } from "libphonenumber-js";
import { isFromShopify } from "./shopify-hmac.js";

const FLOWNALLY_API = "https://api.flownally.com/v1";

// Template and body parameters for each Shopify topic.
const UPDATES = {
  "orders/create": (order) => ({
    template: "order_confirmed",
    parameters: [firstName(order), order.name],
  }),
  "orders/fulfilled": (order) => ({
    template: "order_shipped",
    parameters: [
      firstName(order),
      order.name,
      order.fulfillments?.[0]?.tracking_url || order.order_status_url,
    ],
  }),
};

const app = express();

app.post("/webhooks/shopify", express.raw({ type: "application/json" }), async (req, res) => {
  if (!isFromShopify(req.body, req.get("X-Shopify-Hmac-Sha256"))) {
    return res.sendStatus(401);
  }

  try {
    await sendOrderUpdate(req.get("X-Shopify-Topic"), JSON.parse(req.body));
    res.sendStatus(200);
  } catch (error) {
    // Any status other than 200 makes Shopify deliver the webhook again.
    res.sendStatus(error.retryable === false ? 200 : 503);
  }
});

app.listen(3000);

async function sendOrderUpdate(topic, order) {
  const update = UPDATES[topic]?.(order);
  const phoneNumber = orderPhone(order);
  if (!update || !phoneNumber) return;

  await flownally(
    "/messages",
    {
      inboxId: process.env.FLOWNALLY_INBOX_ID,
      recipient: { type: "phone", phoneNumber },
      content: {
        type: "template",
        template: {
          name: update.template,
          language: "en",
          components: [
            {
              type: "body",
              parameters: update.parameters.map((text) => ({ type: "text", text })),
            },
          ],
        },
      },
    },
    `shopify-order-${order.id}-${update.template}`,
  );
}

// Shopify phone numbers can be in national format, so parse them with the order's country.
function orderPhone(order) {
  const raw =
    order.phone ||
    order.customer?.phone ||
    order.shipping_address?.phone ||
    order.billing_address?.phone;
  const country = order.shipping_address?.country_code || order.billing_address?.country_code;
  const phone = raw ? parsePhoneNumberFromString(raw, country) : undefined;
  return phone?.isValid() ? phone.number : null;
}

function firstName(order) {
  return order.customer?.first_name || "there";
}

async function flownally(path, body, idempotencyKey) {
  const response = await fetch(`${FLOWNALLY_API}${path}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
      "Content-Type": "application/json",
      ...(idempotencyKey && { "Idempotency-Key": idempotencyKey }),
    },
    body: JSON.stringify(body),
  });
  if (!response.ok) {
    throw Object.assign(new Error(`Flownally ${path} returned ${response.status}`), {
      status: response.status,
      retryable: response.status === 429 || response.status >= 500,
    });
  }
  return response.json();
}
```

```python
# server.py
import json
import os

import phonenumbers
import requests
from flask import Flask, request

from shopify_hmac import is_from_shopify

FLOWNALLY_API = "https://api.flownally.com/v1"

# Template and body parameters for each Shopify topic.
UPDATES = {
    "orders/create": lambda order: {
        "template": "order_confirmed",
        "parameters": [first_name(order), order["name"]],
    },
    "orders/fulfilled": lambda order: {
        "template": "order_shipped",
        "parameters": [
            first_name(order),
            order["name"],
            (order.get("fulfillments") or [{}])[0].get("tracking_url") or order["order_status_url"],
        ],
    },
}

app = Flask(__name__)


@app.post("/webhooks/shopify")
def shopify_webhook():
    raw_body = request.get_data()
    if not is_from_shopify(raw_body, request.headers.get("X-Shopify-Hmac-Sha256")):
        return "", 401

    try:
        send_order_update(request.headers.get("X-Shopify-Topic"), json.loads(raw_body))
    except requests.HTTPError as error:
        # Any status other than 200 makes Shopify deliver the webhook again.
        status = error.response.status_code
        if status == 429 or status >= 500:
            return "", 503
    return "", 200


def send_order_update(topic, order):
    update = UPDATES[topic](order) if topic in UPDATES else None
    phone_number = order_phone(order)
    if not update or not phone_number:
        return

    flownally(
        "/messages",
        {
            "inboxId": os.environ["FLOWNALLY_INBOX_ID"],
            "recipient": {"type": "phone", "phoneNumber": phone_number},
            "content": {
                "type": "template",
                "template": {
                    "name": update["template"],
                    "language": "en",
                    "components": [
                        {
                            "type": "body",
                            "parameters": [{"type": "text", "text": text} for text in update["parameters"]],
                        }
                    ],
                },
            },
        },
        f"shopify-order-{order['id']}-{update['template']}",
    )


# Shopify phone numbers can be in national format, so parse them with the order's country.
def order_phone(order):
    customer = order.get("customer") or {}
    shipping = order.get("shipping_address") or {}
    billing = order.get("billing_address") or {}
    raw = order.get("phone") or customer.get("phone") or shipping.get("phone") or billing.get("phone")
    if not raw:
        return None
    phone = phonenumbers.parse(raw, shipping.get("country_code") or billing.get("country_code"))
    if not phonenumbers.is_valid_number(phone):
        return None
    return phonenumbers.format_number(phone, phonenumbers.PhoneNumberFormat.E164)


def first_name(order):
    return (order.get("customer") or {}).get("first_name") or "there"


def flownally(path, body, idempotency_key=None):
    headers = {"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    response = requests.post(f"{FLOWNALLY_API}{path}", headers=headers, json=body)
    response.raise_for_status()
    return response.json()
```

```php
<?php
// shopify-webhook.php
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/shopify-hmac.php';

use GuzzleHttp\Exception\BadResponseException;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;

const FLOWNALLY_API = 'https://api.flownally.com/v1';

$rawBody = file_get_contents('php://input');
if (!isFromShopify($rawBody, $_SERVER['HTTP_X_SHOPIFY_HMAC_SHA256'] ?? null)) {
    http_response_code(401);
    exit;
}

try {
    sendOrderUpdate($_SERVER['HTTP_X_SHOPIFY_TOPIC'] ?? '', json_decode($rawBody, true));
} catch (BadResponseException $error) {
    // Any status other than 200 makes Shopify deliver the webhook again.
    $status = $error->getResponse()->getStatusCode();
    if ($status === 429 || $status >= 500) {
        http_response_code(503);
    }
}

// Template and body parameters for each Shopify topic.
function orderUpdate(string $topic, array $order): ?array
{
    return match ($topic) {
        'orders/create' => [
            'template' => 'order_confirmed',
            'parameters' => [firstName($order), $order['name']],
        ],
        'orders/fulfilled' => [
            'template' => 'order_shipped',
            'parameters' => [
                firstName($order),
                $order['name'],
                ($order['fulfillments'][0]['tracking_url'] ?? null) ?: $order['order_status_url'],
            ],
        ],
        default => null,
    };
}

function sendOrderUpdate(string $topic, array $order): void
{
    $update = orderUpdate($topic, $order);
    $phoneNumber = orderPhone($order);
    if ($update === null || $phoneNumber === null) {
        return;
    }

    flownally('/messages', [
        'inboxId' => getenv('FLOWNALLY_INBOX_ID'),
        'recipient' => ['type' => 'phone', 'phoneNumber' => $phoneNumber],
        'content' => [
            'type' => 'template',
            'template' => [
                'name' => $update['template'],
                'language' => 'en',
                'components' => [
                    [
                        'type' => 'body',
                        'parameters' => array_map(
                            fn ($text) => ['type' => 'text', 'text' => $text],
                            $update['parameters'],
                        ),
                    ],
                ],
            ],
        ],
    ], "shopify-order-{$order['id']}-{$update['template']}");
}

// Shopify phone numbers can be in national format, so parse them with the order's country.
function orderPhone(array $order): ?string
{
    $raw = ($order['phone'] ?? null)
        ?: ($order['customer']['phone'] ?? null)
        ?: ($order['shipping_address']['phone'] ?? null)
        ?: ($order['billing_address']['phone'] ?? null);
    if (!$raw) {
        return null;
    }

    $country = ($order['shipping_address']['country_code'] ?? null)
        ?: ($order['billing_address']['country_code'] ?? null);
    $util = PhoneNumberUtil::getInstance();
    $phone = $util->parse($raw, $country);
    return $util->isValidNumber($phone) ? $util->format($phone, PhoneNumberFormat::E164) : null;
}

function firstName(array $order): string
{
    return ($order['customer']['first_name'] ?? null) ?: 'there';
}

// Guzzle throws on 4xx and 5xx responses.
function flownally(string $path, array $body, ?string $idempotencyKey = null): array
{
    $headers = ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')];
    if ($idempotencyKey !== null) {
        $headers['Idempotency-Key'] = $idempotencyKey;
    }

    $client = new GuzzleHttp\Client();
    $response = $client->post(FLOWNALLY_API . $path, ['headers' => $headers, 'json' => $body]);
    return json_decode((string) $response->getBody(), true);
}
```
:::

A `202` from Flownally means the message was accepted for delivery. Keep the response's `message.id` if you want to check delivery later.

## How should the endpoint handle Shopify retries?

Shopify delivers a webhook again whenever it doesn't get `200`, and it can deliver the same webhook more than once even without errors. **Without a stable [`Idempotency-Key`](/api/idempotency), every repeat would be another WhatsApp message to the customer.**

- **Answer `200` only after Flownally accepts the message.** Any other status makes Shopify try again later.
- **Send the same key on every repeat.** The key combines the Shopify order ID and the template, so a repeated delivery returns the original message instead of sending a new one.
- **Let Shopify retry `429` and `5xx`.** The endpoint answers `503`, which also covers `503` with code `messaging_messaging_send_result_pending`, a response that must be retried with the same key.
- **Stop retries on other `4xx` errors.** Answer `200` and log the `code`, because a retry won't help until the template, inbox or phone number is fixed.
- **Stay under five seconds.** Shopify waits five seconds for the response. If you do more work per order, queue the payload, answer `200`, and send from a worker.

## When can I send free-form text instead?

If the customer messaged you within the last 24 hours, plain text works. Replace `content` with:

```json
{
  "type": "text",
  "text": {
    "body": "Hi Anna, order #1001 is packed and leaves the warehouse today."
  }
}
```

Outside the window, a text message fails with `failureCode` `messaging_window_closed`. Check the window first as shown in [Handle the WhatsApp 24-hour reply window](/api/reply-window).

## Let a journey send Shopify order updates

Templates in code mean a deploy for every wording change. With events, your marketing team changes the template, its variables or the timing in the Flownally app on their own.

**Shopify webhook → Event recorded → Journey sends the template**

Create one event type per update with [Create event type](/api/reference/events#create-event-type). `payloadSchema` lists the payload fields by name, each with a `type`. It's a simplified format, not a JSON Schema document.

:::code
```bash
curl -X POST "https://api.flownally.com/v1/event-types" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order.shipped",
    "description": "Every item in the order was fulfilled.",
    "payloadSchema": {
      "orderNumber": { "type": "string" },
      "firstName": { "type": "string" },
      "trackingUrl": { "type": "string" }
    }
  }'
```

```js
const response = await fetch("https://api.flownally.com/v1/event-types", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "order.shipped",
    description: "Every item in the order was fulfilled.",
    payloadSchema: {
      orderNumber: { type: "string" },
      firstName: { type: "string" },
      trackingUrl: { type: "string" },
    },
  }),
});

if (!response.ok) {
  throw new Error(`Flownally API returned ${response.status}: ${await response.text()}`);
}
```

```python
import os

import requests

response = requests.post(
    "https://api.flownally.com/v1/event-types",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
    json={
        "name": "order.shipped",
        "description": "Every item in the order was fulfilled.",
        "payloadSchema": {
            "orderNumber": {"type": "string"},
            "firstName": {"type": "string"},
            "trackingUrl": {"type": "string"},
        },
    },
)
response.raise_for_status()
```

```php
$client = new GuzzleHttp\Client();

$client->post('https://api.flownally.com/v1/event-types', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    'json' => [
        'name' => 'order.shipped',
        'description' => 'Every item in the order was fulfilled.',
        'payloadSchema' => [
            'orderNumber' => ['type' => 'string'],
            'firstName' => ['type' => 'string'],
            'trackingUrl' => ['type' => 'string'],
        ],
    ],
]);
```
:::

Create `order.confirmed` the same way, with `orderNumber`, `firstName`, `total` (`number`) and `currency`. Then replace `sendOrderUpdate` (`send_order_update` in Python) with a function that finds the contact by phone number and records the event with [Create contact event](/api/reference/events#create-contact-event):

:::code
```js
const EVENTS = {
  "orders/create": (order) => ({
    name: "order.confirmed",
    payload: {
      orderNumber: order.name,
      firstName: firstName(order),
      total: Number(order.total_price),
      currency: order.currency,
    },
  }),
  "orders/fulfilled": (order) => ({
    name: "order.shipped",
    payload: {
      orderNumber: order.name,
      firstName: firstName(order),
      trackingUrl: order.fulfillments?.[0]?.tracking_url || order.order_status_url,
    },
  }),
};

async function sendOrderUpdate(topic, order) {
  const event = EVENTS[topic]?.(order);
  const phone = orderPhone(order);
  if (!event || !phone) return;

  const contactId = await findOrCreateContact(phone, order);
  try {
    await flownally(`/contacts/${contactId}/events`, {
      ...event,
      idempotencyKey: `shopify-order-${order.id}-${event.name}`,
    });
  } catch (error) {
    // 409 means an event with this idempotency key was already recorded.
    if (error.status !== 409) throw error;
  }
}

async function findOrCreateContact(phone, order) {
  const { contacts } = await flownally("/contacts:search", {
    query: {
      match: "all",
      conditions: [
        { type: "customFieldText", fieldId: "cf_phone", operator: "equals", value: phone },
        { type: "archiveStatus", status: "all" },
      ],
    },
    pageSize: 1,
  });
  if (contacts.length > 0) return contacts[0].id;

  const name = [order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(" ");
  const contact = await flownally("/contacts", { name, phone });
  return contact.id;
}
```

```python
EVENTS = {
    "orders/create": lambda order: {
        "name": "order.confirmed",
        "payload": {
            "orderNumber": order["name"],
            "firstName": first_name(order),
            "total": float(order["total_price"]),
            "currency": order["currency"],
        },
    },
    "orders/fulfilled": lambda order: {
        "name": "order.shipped",
        "payload": {
            "orderNumber": order["name"],
            "firstName": first_name(order),
            "trackingUrl": (order.get("fulfillments") or [{}])[0].get("tracking_url")
            or order["order_status_url"],
        },
    },
}


def send_order_update(topic, order):
    event = EVENTS[topic](order) if topic in EVENTS else None
    phone = order_phone(order)
    if not event or not phone:
        return

    contact_id = find_or_create_contact(phone, order)
    try:
        flownally(f"/contacts/{contact_id}/events", {
            **event,
            "idempotencyKey": f"shopify-order-{order['id']}-{event['name']}",
        })
    except requests.HTTPError as error:
        # 409 means an event with this idempotency key was already recorded.
        if error.response.status_code != 409:
            raise


def find_or_create_contact(phone, order):
    found = flownally("/contacts:search", {
        "query": {
            "match": "all",
            "conditions": [
                {"type": "customFieldText", "fieldId": "cf_phone", "operator": "equals", "value": phone},
                {"type": "archiveStatus", "status": "all"},
            ],
        },
        "pageSize": 1,
    })
    if found["contacts"]:
        return found["contacts"][0]["id"]

    customer = order.get("customer") or {}
    name = " ".join(filter(None, [customer.get("first_name"), customer.get("last_name")]))
    return flownally("/contacts", {"name": name, "phone": phone})["id"]
```

```php
function orderEvent(string $topic, array $order): ?array
{
    return match ($topic) {
        'orders/create' => [
            'name' => 'order.confirmed',
            'payload' => [
                'orderNumber' => $order['name'],
                'firstName' => firstName($order),
                'total' => (float) $order['total_price'],
                'currency' => $order['currency'],
            ],
        ],
        'orders/fulfilled' => [
            'name' => 'order.shipped',
            'payload' => [
                'orderNumber' => $order['name'],
                'firstName' => firstName($order),
                'trackingUrl' => ($order['fulfillments'][0]['tracking_url'] ?? null) ?: $order['order_status_url'],
            ],
        ],
        default => null,
    };
}

function sendOrderUpdate(string $topic, array $order): void
{
    $event = orderEvent($topic, $order);
    $phone = orderPhone($order);
    if ($event === null || $phone === null) {
        return;
    }

    $contactId = findOrCreateContact($phone, $order);
    try {
        flownally("/contacts/{$contactId}/events", $event + [
            'idempotencyKey' => "shopify-order-{$order['id']}-{$event['name']}",
        ]);
    } catch (BadResponseException $error) {
        // 409 means an event with this idempotency key was already recorded.
        if ($error->getResponse()->getStatusCode() !== 409) {
            throw $error;
        }
    }
}

function findOrCreateContact(string $phone, array $order): string
{
    $found = flownally('/contacts:search', [
        'query' => [
            'match' => 'all',
            'conditions' => [
                ['type' => 'customFieldText', 'fieldId' => 'cf_phone', 'operator' => 'equals', 'value' => $phone],
                ['type' => 'archiveStatus', 'status' => 'all'],
            ],
        ],
        'pageSize' => 1,
    ]);
    if ($found['contacts'] !== []) {
        return $found['contacts'][0]['id'];
    }

    $customer = $order['customer'] ?? [];
    $name = implode(' ', array_filter([$customer['first_name'] ?? null, $customer['last_name'] ?? null]));
    return flownally('/contacts', ['name' => $name, 'phone' => $phone])['id'];
}
```
:::

Create contact event fails with `400` when the payload doesn't match the event type's fields, and with `404` when the contact or event type doesn't exist.

Then build one journey per event type in the Flownally app, following [How to create a journey](/journeys/create-journey). Start it when the event happens, add a [Send message](/journeys/journey-blocks/send-message-block) step with the template, and fill the template variables from the event details.

:::callout Allow contacts to enter again
A journey lets each contact in once by default. Allow contacts to enter again in the journey's Start block, or each customer gets only their first order update.
:::

## How do I know the customer got the update?

An accepted message can still fail, for example with `failureCode` `template_unavailable` when the template is missing or its parameters don't match, or with `recipient_opted_out`.

- **Poll the message.** Call [Get message](/api/reference/messaging#get-message) with the `message.id` from the send response, and read `deliveryStatus` and `failureCode`.
- **Subscribe to webhooks.** Flownally sends `message.updated` [webhooks](/api/webhooks), signed with Standard Webhooks rather than Shopify's HMAC scheme. Verify them as described in [Verify webhook signatures](/api/webhook-signatures).

## FAQ

### Why does every Shopify webhook fail signature verification?

The body was most likely parsed before the check, so the digest covers serialized JSON instead of the bytes Shopify sent. Read the raw body before anything parses it: `express.raw({ type: "application/json" })` in Express, `request.get_data()` in Flask, or `file_get_contents('php://input')` in PHP. Use the app's client secret as the key. After you rotate the client secret, Shopify can take up to an hour to sign with the new one.

### Why is the phone number missing from the order?

Shopify redacts protected customer fields when your app has no approved access to them. Some customers also don't leave a number at checkout. The endpoint skips orders without a valid number, so check the payload before you look for errors in Flownally.

### Will a repeated webhook send the template twice?

No. A repeated request with the same `Idempotency-Key` returns the original message instead of sending it again. Reusing a key for a different request returns `409` with code `messaging_messaging_idempotency_conflict`.

### Can I send an update for each partial shipment?

Yes, subscribe to `orders/partially_fulfilled` and add a template for it. Build its key from the fulfillment ID, not only the order ID, because an order can be partially fulfilled more than once and each shipment needs its own key.

## Related articles

- [WhatsApp order updates for ecommerce](https://www.flownally.com/blog/whatsapp-order-updates-ecommerce)
- [Send WooCommerce order updates on WhatsApp](/api/woocommerce-order-updates)
- [Retry requests safely with idempotency keys](/api/idempotency)
- [Recover abandoned carts with events and journeys](/api/abandoned-cart-events)
- [Sync store customers and phone numbers](/api/sync-store-customers)
