Handle the WhatsApp 24-hour reply window
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
textormedia. - After it closes, WhatsApp accepts only approved templates, sent as
templatecontent.
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} returns the conversation with a messagingWindow object.
curl "https://api.flownally.com/v1/conversations/cnv_d7pigoip4tfcdebp5mlg" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN"Trimmed response:
{
"id": "cnv_d7pigoip4tfcdebp5mlg",
"channel": "whatsapp",
"inboxId": "ibx_d7pi2efp0k7nba4e1mt0",
"status": "open",
"messagingWindow": {
"state": "OPEN",
"forActor": "AGENT",
"expiresAt": "2026-05-01T10:35:00Z"
}
}stateisOPENorCLOSED.expiresAtis when the window closes.forActorisAGENTorBOT. The window is evaluated for the caller, so read it with the same API key you send with.failureCodeismessagingWindowClosedwhen 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 ownfailureCodein snake_case,messaging_window_closed.
GET /conversations/{id}/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}:
curl "https://api.flownally.com/v1/messages/msg_d7pj3rljs4umccbo6tdg" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN"Trimmed response:
{
"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, notfailureReason.failureReasonis one English sentence for logs and display. - Treat any
failureCodeyou don't recognize asdelivery_failed.
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 returns the templates synced from Meta for a WhatsApp inbox. Use the conversation's inboxId, or find the inbox with GET /inboxes.
curl "https://api.flownally.com/v1/inboxes/ibx_d7pi2efp0k7nba4e1mt0/whatsapp/templates" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN"{
"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
statusAPPROVED. The list can also includePENDINGtemplates. - Copy
nameandlanguageexactly as listed. - Fill every placeholder. Each
{{n}}placeholder incomponents[].textneeds a parameter when you send. - Page through long lists. The list returns 100 templates by default. Set
pageSizeup to 1000 and follownextPageTokenwithpageToken.
To estimate what template messages cost, use the WhatsApp pricing calculator. For how utility message charges changed, see WhatsApp Business API pricing changes in October 2026.
Send a WhatsApp template message
Send template content with POST /messages:
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" }
]
}
]
}
}
}'componentsfills the placeholders. Abodycomponent'sparametersfill{{1}},{{2}}and so on, in order. Templates with header or button parameters useheaderandbuttoncomponents.- Continue a conversation by its ID. Send
"recipient": { "type": "conversation", "conversationId": "cnv_d7pigoip4tfcdebp5mlg" }.inboxIdis required only forcontactandphonerecipients. - Reply in a known conversation.
POST /conversations/{id}/messagesaccepts the samecontent.
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, 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
409with codemessaging_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
503with codemessaging_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 does:
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`,
);
}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?
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?
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?
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 explains.
Why did my template fail with template_unavailable?
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.