# Handle the WhatsApp 24-hour reply window

:::summary Quick answer
WhatsApp delivers free-form messages only within 24 hours of the customer's last message. Before you send, read `messagingWindow.state` from the conversation: send free-form content when it's `OPEN`, and an approved template when it's `CLOSED`. Send every message with an `Idempotency-Key`, so a retry never delivers it twice.
:::

A free-form message sent after the window closes fails with `failureCode` `messaging_window_closed`. To reach the customer then, send an approved WhatsApp template.

## How does the WhatsApp 24-hour reply window work?

- **Each customer message opens the window for 24 hours.** The next customer message starts the 24 hours again.
- **While it's open, you can send any content type,** such as `text` or `media`.
- **After it closes, WhatsApp accepts only approved templates,** sent as `template` content.

:::callout Don't compute the deadline from message timestamps
On inbound messages, `createdAt` is when Flownally stored the message, not when the customer sent it. Read `messagingWindow` from the conversation instead.
:::

## Check the reply window before you send

[`GET /conversations/{id}`](/api/reference/messaging#get-conversation) returns the conversation with a `messagingWindow` object.

:::code
```bash
curl "https://api.flownally.com/v1/conversations/cnv_d7pigoip4tfcdebp5mlg" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN"
```

```js
const response = await fetch("https://api.flownally.com/v1/conversations/cnv_d7pigoip4tfcdebp5mlg", {
  headers: { Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}` },
});
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);

const conversation = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.flownally.com/v1/conversations/cnv_d7pigoip4tfcdebp5mlg",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
)
response.raise_for_status()
conversation = response.json()
```

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

$response = $client->get('https://api.flownally.com/v1/conversations/cnv_d7pigoip4tfcdebp5mlg', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
]);

$conversation = json_decode((string) $response->getBody(), true);
```
:::

Trimmed response:

```json
{
  "id": "cnv_d7pigoip4tfcdebp5mlg",
  "channel": "whatsapp",
  "inboxId": "ibx_d7pi2efp0k7nba4e1mt0",
  "status": "open",
  "messagingWindow": {
    "state": "OPEN",
    "forActor": "AGENT",
    "expiresAt": "2026-05-01T10:35:00Z"
  }
}
```

- `state` is `OPEN` or `CLOSED`.
- `expiresAt` is when the window closes.
- `forActor` is `AGENT` or `BOT`. The window is evaluated for the caller, so read it with the same API key you send with.
- `failureCode` is `messagingWindowClosed` when the window has closed for this caller. This is the conversation's window state, in camelCase; a message that failed for the same reason reports its own `failureCode` in snake_case, `messaging_window_closed`.

[`GET /conversations/{id}/messages`](/api/reference/messaging#list-conversation-messages) returns the same object in `conversation.messagingWindow`, unless you filter by `sessionId`.

**If `state` is `OPEN`, send free-form content. If it's `CLOSED`, send a template.**

## Handle messaging_window_closed delivery failures

The window can close between your check and the delivery.

Send endpoints answer `202 Accepted` as soon as Flownally accepts the message, before WhatsApp delivers it. If WhatsApp then refuses the message, it moves to `deliveryStatus` `failed` with `failureCode` `messaging_window_closed`.

WhatsApp's own API reports this condition as error `131047`. Flownally doesn't pass provider error codes through, so branch on `messaging_window_closed`.

Follow delivery state with the `message.updated` webhook, or poll [`GET /messages/{id}`](/api/reference/messaging#get-message):

:::code
```bash
curl "https://api.flownally.com/v1/messages/msg_d7pj3rljs4umccbo6tdg" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN"
```

```js
const response = await fetch("https://api.flownally.com/v1/messages/msg_d7pj3rljs4umccbo6tdg", {
  headers: { Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}` },
});
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);

const { message } = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.flownally.com/v1/messages/msg_d7pj3rljs4umccbo6tdg",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
)
response.raise_for_status()
message = response.json()["message"]
```

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

$response = $client->get('https://api.flownally.com/v1/messages/msg_d7pj3rljs4umccbo6tdg', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
]);

$message = json_decode((string) $response->getBody(), true)['message'];
```
:::

Trimmed response:

```json
{
  "message": {
    "id": "msg_d7pj3rljs4umccbo6tdg",
    "conversationId": "cnv_d7pigoip4tfcdebp5mlg",
    "content": {
      "type": "text",
      "text": { "body": "Hi Anna, your order is ready for pickup." }
    },
    "deliveryStatus": "failed",
    "failureCode": "messaging_window_closed",
    "clientMessageId": "order-4827-ready-text"
  }
}
```

When you see `messaging_window_closed`:

- **Send a template instead.** Don't resend the same free-form message.
- **Branch on `failureCode`, not `failureReason`.** `failureReason` is one English sentence for logs and display.
- **Treat any `failureCode` you don't recognize as `delivery_failed`.**

:::callout Don't resend a message in unknown state
A message in `deliveryStatus` `unknown` may have reached the customer. Don't resend it automatically.
:::

If the send request itself fails with a `ProblemDetails` error instead of `202`, reload the conversation and check `messagingWindow.state` before you choose the next step.

## List approved WhatsApp templates

[`GET /inboxes/{id}/whatsapp/templates`](/api/reference/inboxes#list-whats-app-templates) returns the templates synced from Meta for a WhatsApp inbox. Use the conversation's `inboxId`, or find the inbox with `GET /inboxes`.

:::code
```bash
curl "https://api.flownally.com/v1/inboxes/ibx_d7pi2efp0k7nba4e1mt0/whatsapp/templates" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN"
```

```js
const response = await fetch(
  "https://api.flownally.com/v1/inboxes/ibx_d7pi2efp0k7nba4e1mt0/whatsapp/templates",
  { headers: { Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}` } },
);
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);

const { templates } = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.flownally.com/v1/inboxes/ibx_d7pi2efp0k7nba4e1mt0/whatsapp/templates",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
)
response.raise_for_status()
templates = response.json()["templates"]
```

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

$response = $client->get('https://api.flownally.com/v1/inboxes/ibx_d7pi2efp0k7nba4e1mt0/whatsapp/templates', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
]);

$templates = json_decode((string) $response->getBody(), true)['templates'];
```
:::

```json
{
  "templates": [
    {
      "id": "987654321",
      "name": "order_ready",
      "language": "en",
      "status": "APPROVED",
      "category": "UTILITY",
      "components": [
        {
          "type": "BODY",
          "text": "Hi {{1}}, your order {{2}} is ready for pickup."
        }
      ]
    }
  ]
}
```

- **Send only approved templates.** Use templates with `status` `APPROVED`. The list can also include `PENDING` templates.
- **Copy `name` and `language` exactly as listed.**
- **Fill every placeholder.** Each `{{n}}` placeholder in `components[].text` needs a parameter when you send.
- **Page through long lists.** The list returns 100 templates by default. Set `pageSize` up to 1000 and follow `nextPageToken` with `pageToken`.

To estimate what template messages cost, use the [WhatsApp pricing calculator](https://www.flownally.com/whatsapp-pricing). For how utility message charges changed, see [WhatsApp Business API pricing changes in October 2026](https://www.flownally.com/blog/whatsapp-business-api-pricing-changes-october-2026).

## Send a WhatsApp template message

Send `template` content with [`POST /messages`](/api/reference/messaging#send-message):

:::code
```bash
curl -X POST "https://api.flownally.com/v1/messages" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
  -H "Idempotency-Key: order-4827-ready-template" \
  -H "Content-Type: application/json" \
  -d '{
    "inboxId": "ibx_d7pi2efp0k7nba4e1mt0",
    "recipient": {
      "type": "phone",
      "phoneNumber": "+48123456789"
    },
    "content": {
      "type": "template",
      "template": {
        "name": "order_ready",
        "language": "en",
        "components": [
          {
            "type": "body",
            "parameters": [
              { "type": "text", "text": "Anna" },
              { "type": "text", "text": "4827" }
            ]
          }
        ]
      }
    }
  }'
```

```js
const response = await fetch("https://api.flownally.com/v1/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
    "Idempotency-Key": "order-4827-ready-template",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    inboxId: "ibx_d7pi2efp0k7nba4e1mt0",
    recipient: { type: "phone", phoneNumber: "+48123456789" },
    content: {
      type: "template",
      template: {
        name: "order_ready",
        language: "en",
        components: [
          {
            type: "body",
            parameters: [
              { type: "text", text: "Anna" },
              { type: "text", text: "4827" },
            ],
          },
        ],
      },
    },
  }),
});
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);

const { message } = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.flownally.com/v1/messages",
    headers={
        "Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}",
        "Idempotency-Key": "order-4827-ready-template",
    },
    json={
        "inboxId": "ibx_d7pi2efp0k7nba4e1mt0",
        "recipient": {"type": "phone", "phoneNumber": "+48123456789"},
        "content": {
            "type": "template",
            "template": {
                "name": "order_ready",
                "language": "en",
                "components": [
                    {
                        "type": "body",
                        "parameters": [
                            {"type": "text", "text": "Anna"},
                            {"type": "text", "text": "4827"},
                        ],
                    }
                ],
            },
        },
    },
)
response.raise_for_status()
message = response.json()["message"]
```

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

$response = $client->post('https://api.flownally.com/v1/messages', [
    'headers' => [
        'Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN'),
        'Idempotency-Key' => 'order-4827-ready-template',
    ],
    'json' => [
        'inboxId' => 'ibx_d7pi2efp0k7nba4e1mt0',
        'recipient' => ['type' => 'phone', 'phoneNumber' => '+48123456789'],
        'content' => [
            'type' => 'template',
            'template' => [
                'name' => 'order_ready',
                'language' => 'en',
                'components' => [
                    [
                        'type' => 'body',
                        'parameters' => [
                            ['type' => 'text', 'text' => 'Anna'],
                            ['type' => 'text', 'text' => '4827'],
                        ],
                    ],
                ],
            ],
        ],
    ],
]);

$message = json_decode((string) $response->getBody(), true)['message'];
```
:::

- **`components` fills the placeholders.** A `body` component's `parameters` fill `{{1}}`, `{{2}}` and so on, in order. Templates with header or button parameters use `header` and `button` components.
- **Continue a conversation by its ID.** Send `"recipient": { "type": "conversation", "conversationId": "cnv_d7pigoip4tfcdebp5mlg" }`. `inboxId` is required only for `contact` and `phone` recipients.
- **Reply in a known conversation.** [`POST /conversations/{id}/messages`](/api/reference/messaging#send-conversation-message) accepts the same `content`.

The response is `202 Accepted` with the new message.

If the template is missing, paused or disabled, or its parameters don't match, the message fails with `failureCode` `template_unavailable`.

## Retry message sends safely with Idempotency-Key

Without an [`Idempotency-Key`](/api/idempotency), a retried request can send the message twice. **Send an `Idempotency-Key` header with every message.**

- **Use a new key for every message you intend to send.** Derive it from your own event, such as `order-4827-ready-template`, so a restarted job reuses it.
- **Retry with the same key and the same body.** Flownally returns the original message, with the same `id`, instead of sending it again.
- **Keys belong to the workspace.** They aren't scoped to an API key or a conversation, and they can be 1 to 255 characters long.
- **Don't reuse a key for a different request.** Reusing a key for a different request or sender within 24 hours returns `409` with code `messaging_messaging_idempotency_conflict`. Use a different key for the template than for the free-form message it replaces.
- **Retry within 24 hours.** A key's guarantee lasts 24 hours, so finish retries within that window and never reuse a key for a new message.
- **Retry a pending send with the same key.** Do this after `503` with code `messaging_messaging_send_result_pending`.
- **Find the key on the message.** It comes back as `clientMessageId`.

This example checks the window, picks free-form or template content, and sends it with a key built from the order. To retry network errors, `429` and `5xx` responses, send the same request again with the same key, as the retry helper in [Idempotency keys](/api/idempotency) does:

:::code
```js
const API = "https://api.flownally.com/v1";
const headers = {
  Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
  "Content-Type": "application/json",
};

async function sendMessage(body, idempotencyKey) {
  const response = await fetch(`${API}/messages`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": idempotencyKey },
    body: JSON.stringify(body),
  });
  if (!response.ok) throw new Error(`Send failed: ${response.status}`);
  return response.json();
}

export async function notifyOrderReady({ conversationId, orderId, customerName }) {
  const response = await fetch(`${API}/conversations/${conversationId}`, { headers });
  const conversation = await response.json();
  if (conversation.messagingWindow?.state !== "OPEN") {
    return sendTemplate({ conversationId, orderId, customerName });
  }

  const text = `Hi ${customerName}, your order ${orderId} is ready for pickup.`;
  return sendMessage(
    {
      recipient: { type: "conversation", conversationId },
      content: { type: "text", text: { body: text } },
    },
    `order-${orderId}-ready-text`,
  );
}

export function sendTemplate({ conversationId, orderId, customerName }) {
  return sendMessage(
    {
      recipient: { type: "conversation", conversationId },
      content: {
        type: "template",
        template: {
          name: "order_ready",
          language: "en",
          components: [
            {
              type: "body",
              parameters: [
                { type: "text", text: customerName },
                { type: "text", text: orderId },
              ],
            },
          ],
        },
      },
    },
    `order-${orderId}-ready-template`,
  );
}
```

```python
import os

import requests

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

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"


def send_message(body, idempotency_key):
    response = session.post(f"{API}/messages", json=body, headers={"Idempotency-Key": idempotency_key})
    response.raise_for_status()
    return response.json()


def notify_order_ready(conversation_id, order_id, customer_name):
    conversation = session.get(f"{API}/conversations/{conversation_id}").json()
    if conversation.get("messagingWindow", {}).get("state") != "OPEN":
        return send_template(conversation_id, order_id, customer_name)

    text = f"Hi {customer_name}, your order {order_id} is ready for pickup."
    return send_message(
        {
            "recipient": {"type": "conversation", "conversationId": conversation_id},
            "content": {"type": "text", "text": {"body": text}},
        },
        f"order-{order_id}-ready-text",
    )


def send_template(conversation_id, order_id, customer_name):
    return send_message(
        {
            "recipient": {"type": "conversation", "conversationId": conversation_id},
            "content": {
                "type": "template",
                "template": {
                    "name": "order_ready",
                    "language": "en",
                    "components": [
                        {
                            "type": "body",
                            "parameters": [
                                {"type": "text", "text": customer_name},
                                {"type": "text", "text": order_id},
                            ],
                        }
                    ],
                },
            },
        },
        f"order-{order_id}-ready-template",
    )
```

```php
const API = 'https://api.flownally.com/v1';

function flownally(): GuzzleHttp\Client
{
    static $client;
    return $client ??= new GuzzleHttp\Client([
        'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    ]);
}

function sendMessage(array $body, string $idempotencyKey): array
{
    $response = flownally()->post(API . '/messages', [
        'headers' => ['Idempotency-Key' => $idempotencyKey],
        'json' => $body,
    ]);
    return json_decode((string) $response->getBody(), true);
}

function notifyOrderReady(string $conversationId, string $orderId, string $customerName): array
{
    $response = flownally()->get(API . "/conversations/{$conversationId}");
    $conversation = json_decode((string) $response->getBody(), true);
    if (($conversation['messagingWindow']['state'] ?? null) !== 'OPEN') {
        return sendTemplate($conversationId, $orderId, $customerName);
    }

    return sendMessage([
        'recipient' => ['type' => 'conversation', 'conversationId' => $conversationId],
        'content' => [
            'type' => 'text',
            'text' => ['body' => "Hi {$customerName}, your order {$orderId} is ready for pickup."],
        ],
    ], "order-{$orderId}-ready-text");
}

function sendTemplate(string $conversationId, string $orderId, string $customerName): array
{
    return sendMessage([
        'recipient' => ['type' => 'conversation', 'conversationId' => $conversationId],
        'content' => [
            'type' => 'template',
            'template' => [
                'name' => 'order_ready',
                'language' => 'en',
                'components' => [
                    [
                        'type' => 'body',
                        'parameters' => [
                            ['type' => 'text', 'text' => $customerName],
                            ['type' => 'text', 'text' => $orderId],
                        ],
                    ],
                ],
            ],
        ],
    ], "order-{$orderId}-ready-template");
}
```
:::

If the text message later fails with `messaging_window_closed`, call `sendTemplate` (`send_template` in Python). It uses its own key, so it doesn't conflict with the failed text message, and a repeated call doesn't send the template twice.

## FAQ

### Why did my message fail with `messaging_window_closed`?

The 24-hour window closed before WhatsApp delivered the message, which can happen between your check and the delivery. Don't resend the same free-form message. Send an approved template instead, with its own `Idempotency-Key`.

### Can I work out the window from message timestamps?

No. On inbound messages, `createdAt` is when Flownally stored the message, not when the customer sent it. Read `messagingWindow` from the conversation, with the same API key you send with.

### Does Flownally return WhatsApp error `131047`?

No. WhatsApp's own API reports a closed window as error `131047`, but Flownally doesn't pass provider error codes through. Branch on `messaging_window_closed`.

### Why does my template send return `409`?

The key was used in the last 24 hours for a different request or sender, so Flownally sends nothing and returns `messaging_messaging_idempotency_conflict`. Give the template its own key, as [Idempotency keys](/api/idempotency) explains.

### Why did my template fail with `template_unavailable`?

The template is missing, paused or disabled, or its parameters don't match. Check `status`, `name` and `language` in the template list, and send one parameter for each `{{n}}` placeholder.

## Related articles

- [Retry requests safely with idempotency keys](/api/idempotency)
- [Build an AI reply agent](/api/ai-reply-agent)
- [Verify webhook signatures](/api/webhook-signatures)
- [Webhooks](/api/webhooks)
- [Errors](/api/errors)
