Skip to content
Recipes

Send WhatsApp Order Notifications

You run an ecommerce shop. When an order ships, you want the customer to receive a WhatsApp message with the tracking link — reliably, once, and with full visibility into whether it actually arrived.

This recipe wires together an approved Meta template, an idempotent send keyed by your order id, and a message.status webhook so you can tell delivery apart from “Meta accepted it but it bounced an hour later”.

┌──────────────┐ order.shipped ┌──────────────┐ POST /v1/.../messages ┌──────────┐
│ Your shop │ ─────────────────► │ Your backend │ ────────────────────────► │ kirim.dev│
│ (Shopify/ │ │ (this guide) │ └────┬─────┘
│ custom) │ └──────┬───────┘ │
└──────────────┘ │ forwards │
│ ▼
┌──────▼────────┐ message.status ┌──────────┐
│ Webhook │ ◄───────────────────── │ Meta │
│ /kirim-hook │ (delivered/failed) └────┬─────┘
└───────────────┘ │
┌──────────┐
│ Customer │
└──────────┘

Two hops, one direction for the send, one direction for the status — everything driven by webhooks so nothing polls.

  • A kirim.dev API key (kdv_live_…). See Authentication.

  • A connected WhatsApp Business account (resolve its phone_number_id via GET /v1/accounts — see step 2).

  • A Meta-approved template named your_order_shipped. The shape we assume in this recipe:

    ComponentContent
    Header (text)Order #{{1}}
    Body (text)Hi {{1}}, your order is on its way! Track it here:
    Button (URL)dynamic suffix {{1}} appended to a fixed base URL

    Templates are approved inside Meta Business Manager — kirim.dev doesn’t gate the approval, it forwards your placeholders to Meta verbatim.

  1. Approve the template in Meta Business Manager.

    Submit your_order_shipped with the three components above. Meta typically responds within a few minutes for transactional templates. Once approved, it appears in GET /v1/{phone_number_id}/templates with status: "APPROVED".

  2. Resolve your phone_number_id.

    Every send and every template lookup is scoped to a specific connected WhatsApp number. Pull the list once at boot:

    import { Kirim } from '@kirimdev/sdk'
    const kirim = new Kirim({ apiKey: process.env.KIRIM_KEY! })
    const accounts = await kirim.accounts.list().page()
    const shop = accounts.data.find((a) => a.display_phone_number === '+628111222333')
    if (!shop) throw new Error('shop WA account not connected')
    const PHONE_NUMBER_ID = shop.phone_number_id // e.g. "106540352242922"

    Cache PHONE_NUMBER_ID — it never changes for a given connected account. See Resource IDs for the full shape.

  3. Hook your shop’s order.shipped event.

    Whatever ecommerce platform you use (Shopify, WooCommerce, custom Postgres trigger), funnel “this order shipped” events into one HTTP handler. We’ll use a tiny Hono server below; swap for your framework freely.

  4. Build an idempotent template send.

    The idempotency key is derived from the order id, not random. That way retries from your shop’s webhook, queue redeliveries, or your own re-run scripts all collapse to a single send:

    import { createHash } from 'node:crypto'
    const idempotencyKey = (orderId: string) =>
    createHash('sha256').update(`shipped:${orderId}`).digest('hex')

    Pass it via the SDK’s per-request override:

    await kirim.phoneNumbers(PHONE_NUMBER_ID).messages.send(
    {
    messaging_product: 'whatsapp',
    to: customer.phone, // '+628999888777'
    type: 'template',
    template: {
    name: 'your_order_shipped',
    language: { code: 'id_ID' },
    components: [
    {
    type: 'header',
    parameters: [{ type: 'text', text: order.id }],
    },
    {
    type: 'body',
    parameters: [{ type: 'text', text: customer.first_name }],
    },
    {
    type: 'button',
    sub_type: 'url',
    index: '0',
    parameters: [{ type: 'text', text: order.tracking_slug }],
    },
    ],
    },
    },
    { idempotencyKey: idempotencyKey(order.id) },
    )

    Re-running this with the same order.id returns the cached response with Idempotent-Replayed: true — see Idempotency.

  5. Subscribe to delivery callbacks.

    Webhook subscriptions are org-level (root scope), not per-phone:

    const sub = await kirim.webhookSubscriptions.create({
    url: 'https://your-shop.example/kirim-hook',
    events: ['message.status'],
    })
    console.log(sub.signing_secret) // store this in your secret manager — shown ONCE

    See Webhooks overview and Webhook signing for the verification contract.

  6. Handle failed status with a graceful fallback.

    The customer’s WhatsApp may be unreachable (recipient_not_on_whatsapp), their phone number invalid (invalid_recipient_phone), or Meta may drop the message after accepting it. Catch those in your message.status handler and escalate.

import { Hono } from 'hono'
import { Kirim } from '@kirimdev/sdk'
import { verifyWebhookSignature } from '@kirimdev/sdk/webhooks'
import { createHash } from 'node:crypto'
const kirim = new Kirim({ apiKey: process.env.KIRIM_KEY! })
const PHONE_NUMBER_ID = '106540352242922'
const app = new Hono()
// 1. Your shop fires this when an order ships.
app.post('/internal/order-shipped', async (c) => {
const { order, customer } = await c.req.json()
const key = createHash('sha256').update(`shipped:${order.id}`).digest('hex')
const msg = await kirim.phoneNumbers(PHONE_NUMBER_ID).messages.send(
{
messaging_product: 'whatsapp',
to: customer.phone,
type: 'template',
template: {
name: 'your_order_shipped',
language: { code: 'id_ID' },
components: [
{ type: 'header', parameters: [{ type: 'text', text: order.id }] },
{ type: 'body', parameters: [{ type: 'text', text: customer.first_name }] },
{
type: 'button',
sub_type: 'url',
index: '0',
parameters: [{ type: 'text', text: order.tracking_slug }],
},
],
},
},
{ idempotencyKey: key },
)
await db.shipped_sends.upsert({ order_id: order.id, message_id: msg.id })
return c.json({ ok: true, message_id: msg.id })
})
// 2. Kirim posts delivery updates here.
app.post('/kirim-hook', async (c) => {
const raw = await c.req.text()
const ok = await verifyWebhookSignature(
raw,
c.req.header('x-kirim-signature'),
process.env.KIRIM_WEBHOOK_SECRET!,
)
if (!ok) return c.text('bad signature', 401)
const event = JSON.parse(raw)
if (event.type !== 'message.status') return c.text('ok')
const { id, status, error } = event.data
await db.shipped_sends.update({ where: { message_id: id }, data: { status } })
if (status === 'failed') {
// Escalate — try email fallback, or alert your ops channel.
await fallbackEmail(id, error)
}
return c.text('ok')
})
export default app

Idempotency contract

The header semantics, replay rules, and the Idempotent-Replayed response header. → /concepts/idempotency/

Handle failed sends

Branching on error.code, retry strategies per failure class. → /guides/failed-sends/

Webhook signing

Verify X-Kirim-Signature so nobody fakes a delivered status. → /webhooks/signing/