API v2 (beta)
Mensagens
Envie uma mensagem agora ou em um horário definido, para um número de telefone, um grupo de WhatsApp, um contato salvo ou um nome de usuário do Telegram. Envie a mesma mensagem para até 1.000 destinatários em uma única requisição, leia o histórico, acompanhe o status de uma mensagem e cancele-a enquanto ela espera.
| Operação | Endpoint | Permissão |
|---|---|---|
| Enviar uma mensagem | POST /api/v2/messages | messages:send |
| Enviar para vários destinatários | POST /api/v2/messages/batch | messages:send |
| Listar mensagens | GET /api/v2/messages | messages:read |
| Obter uma mensagem | GET /api/v2/messages/{id} | messages:read |
| Cancelar uma mensagem | DELETE /api/v2/messages/{id} | messages:send |
Chaves, erros, Idempotency-Key e limites funcionam como descrito na visão geral da API v2. Para saber quando uma mensagem é enviada ou falha sem consultar periodicamente, adicione um endpoint de webhook.
Enviar uma mensagem#
https://wbiztool.com/api/v2/messagesCorpo: JSON (application/json)
A mensagem entra na fila e a resposta chega antes de ela sair, com HTTP 202 e a Message no status queued (ou scheduled, com um send_at no futuro). Acompanhe-a com Obter uma mensagem ou com um webhook.
curl -X POST https://wbiztool.com/api/v2/messages \
-H "Authorization: Bearer $WBIZTOOL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-ready" \
-d '{
"from": 101,
"to": { "phone": "+919876543210" },
"text": "Hi Asha, your order #1042 is ready."
}'import os
import requests
response = requests.post(
"https://wbiztool.com/api/v2/messages",
headers={
"Authorization": f"Bearer {os.environ['WBIZTOOL_API_KEY']}",
"Idempotency-Key": "order-1042-ready",
},
json={
"from": 101,
"to": {"phone": "+919876543210"},
"text": "Hi Asha, your order #1042 is ready.",
},
timeout=30,
)
body = response.json()
if response.status_code == 202:
print("Queued:", body["id"], body["status"])
else:
print("Refused:", body["error"]["code"], body["error"]["message"])// Node.js 18+ (built-in fetch). Save as .mjs to use top-level await.
const response = await fetch("https://wbiztool.com/api/v2/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WBIZTOOL_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "order-1042-ready",
},
body: JSON.stringify({
from: 101,
to: { phone: "+919876543210" },
text: "Hi Asha, your order #1042 is ready.",
}),
});
const body = await response.json();
if (response.status === 202) {
console.log("Queued:", body.id, body.status);
} else {
console.error("Refused:", body.error.code, body.error.message);
}<?php
$ch = curl_init('https://wbiztool.com/api/v2/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WBIZTOOL_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-1042-ready',
],
CURLOPT_POSTFIELDS => json_encode([
'from' => 101,
'to' => ['phone' => '+919876543210'],
'text' => 'Hi Asha, your order #1042 is ready.',
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 202) {
echo 'Queued: ' . $body['id'] . ' ' . $body['status'];
} else {
echo 'Refused: ' . $body['error']['code'] . ' ' . $body['error']['message'];
}Resposta (202):
{
"id": 9817263,
"status": "queued",
"from": { "id": 101, "phone": "919800000000", "platform": "whatsapp" },
"to": { "phone": "+919876543210", "contact_id": 991 },
"text": "Hi Asha, your order #1042 is ready.",
"media": null,
"send_at": null,
"expires_at": null,
"created_at": "2026-10-18T10:15:02.311Z",
"sent_at": null,
"error": null
}
Corpo
toobjectobrigatórioQuem recebe: exatamente um de
phone,group,contact_idouusername. Veja Destinatários.frominteger or nullObrigatório, a menos que o espaço de trabalho tenha um único número conectadoO
iddo número que envia, obtido em Listar números. Se for omitido, o único número conectado envia: um número de WhatsApp conectado é usado mesmo que uma conta do Telegram também esteja conectada, e uma conta do Telegram só quando nenhum número de WhatsApp está conectado. Um número desconectado é aceito: as mensagens dele esperam até ele se reconectar.textstringObrigatório, a menos que media seja enviadoA mensagem, com até 3.000 caracteres. Com
media, é a legenda e pode ser omitida. A formatação do WhatsApp funciona:*bold*,_italic_,~strikethrough~. Em JSON, escreva as quebras de linha como\n.mediaobject or nullopcionalUma imagem ou um arquivo para enviar. Veja Mídia.
send_atstring or nullopcionalEnviar neste horário em vez de agora: ISO 8601 com o deslocamento, como
2026-10-20T09:30:00+05:30. Precisa estar no futuro. Veja Agendamento e expiração.expires_atstring or nullopcionalNão enviar depois deste horário: a mensagem passa a
expired. ISO 8601 com o deslocamento, no futuro e depois desend_at.
Cabeçalhos
AuthorizationheaderobrigatórioBearer wbz_live_…, uma chave com a permissãomessages:send.Idempotency-KeyheaderopcionalQualquer string única, para que uma nova tentativa nunca envie uma segunda mensagem. Veja Idempotency-Key.
Erros ao enviar#
| HTTP | Código | Quando |
|---|---|---|
400 | validation_failed | Um campo está faltando, tem o tipo errado ou é desconhecido; to não tem nenhum ou tem vários dos seus campos; media tem url e media_id juntos ou nenhum dos dois; expires_at não é depois de send_at |
400 | text_required | Sem text e sem media |
400 | text_too_long | text tem mais de 3.000 caracteres |
400 | invalid_datetime | send_at ou expires_at não está em ISO 8601 com o deslocamento |
402 | no_credits | O espaço de trabalho não tem mais mensagens no plano |
403 | number_not_allowed | A chave está limitada a outros números |
403 | account_disabled | A conta do proprietário do espaço de trabalho está desativada |
422 | invalid_phone | to.phone não é um número de telefone no formato internacional com + |
422 | invalid_username | to.username não é um nome de usuário, link ou id tg: do Telegram |
422 | username_needs_telegram | to.username com um número de WhatsApp como from |
422 | contact_not_found | Nenhum contato com esse to.contact_id |
422 | media_not_found | Nenhum arquivo com esse media.media_id |
422 | invalid_media_url | media.url não é um link público http(s) |
422 | time_in_past | send_at ou expires_at já passou |
422 | number_not_found | from não é um número deste espaço de trabalho |
422 | from_required | Sem from, e o espaço de trabalho não tem nenhum número conectado ou tem vários |
422 | message_rejected | A mensagem não pôde entrar na fila; message diz por quê |
429 | number_daily_cap_reached | O número enviou 1.000 mensagens nas últimas 24 horas |
Um envio recusado não deixa nada para trás: nenhuma mensagem é guardada, então não há nada para cancelar. Os erros que podem acontecer em qualquer requisição (chaves, permissões, limites de requisições, Idempotency-Key) estão na página de erros.
Enviar para vários destinatários#
https://wbiztool.com/api/v2/messages/batchCorpo: JSON (application/json)
Envia a mesma mensagem para até 1.000 destinatários. O corpo é o mesmo de Enviar uma mensagem, exceto que to é uma lista de destinatários.
- Tudo ou nada. Todos os destinatários são verificados antes de qualquer coisa entrar na fila, e a lista inteira precisa caber nas mensagens restantes do plano e no espaço do número que envia para as próximas 24 horas. Se um destinatário estiver errado, nada entra na fila, e
fieldindica qual, comoto[3].phone. - O mesmo destinatário duas vezes recebe uma única mensagem.
- A resposta é
202comdata: uma Message por destinatário, na ordem deto.
curl -X POST https://wbiztool.com/api/v2/messages/batch \
-H "Authorization: Bearer $WBIZTOOL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: opening-hours-2026-10-20" \
-d '{
"from": 101,
"to": [
{ "phone": "+919876543210" },
{ "phone": "+447700900123" },
{ "group": "Bakery staff" }
],
"text": "We open at 10 tomorrow."
}'import os
import requests
recipients = ["+919876543210", "+447700900123"]
response = requests.post(
"https://wbiztool.com/api/v2/messages/batch",
headers={
"Authorization": f"Bearer {os.environ['WBIZTOOL_API_KEY']}",
"Idempotency-Key": "opening-hours-2026-10-20",
},
json={
"from": 101,
"to": [{"phone": p} for p in recipients] + [{"group": "Bakery staff"}],
"text": "We open at 10 tomorrow.",
},
timeout=60,
)
body = response.json()
if response.status_code == 202:
for message in body["data"]:
print(message["id"], message["to"], message["status"])
else:
print("Refused:", body["error"]["code"], body["error"]["field"], body["error"]["message"])// Node.js 18+ (built-in fetch). Save as .mjs to use top-level await.
const recipients = ["+919876543210", "+447700900123"];
const response = await fetch("https://wbiztool.com/api/v2/messages/batch", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WBIZTOOL_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "opening-hours-2026-10-20",
},
body: JSON.stringify({
from: 101,
to: [...recipients.map((phone) => ({ phone })), { group: "Bakery staff" }],
text: "We open at 10 tomorrow.",
}),
});
const body = await response.json();
if (response.status === 202) {
for (const message of body.data) console.log(message.id, message.to, message.status);
} else {
console.error("Refused:", body.error.code, body.error.field, body.error.message);
}<?php
$recipients = ['+919876543210', '+447700900123'];
$to = array_map(fn ($phone) => ['phone' => $phone], $recipients);
$to[] = ['group' => 'Bakery staff'];
$ch = curl_init('https://wbiztool.com/api/v2/messages/batch');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WBIZTOOL_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: opening-hours-2026-10-20',
],
CURLOPT_POSTFIELDS => json_encode(['from' => 101, 'to' => $to, 'text' => 'We open at 10 tomorrow.']),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 202) {
foreach ($body['data'] as $message) {
echo $message['id'] . ' ' . $message['status'] . "\n";
}
} else {
echo 'Refused: ' . $body['error']['code'] . ' ' . $body['error']['field'];
}Resposta (202), uma Message por destinatário; a segunda e a terceira são iguais à primeira, com to igual a {"phone": "+447700900123", …} e {"group": "Bakery staff", …}:
{
"data": [
{
"id": 9817264,
"status": "queued",
"from": { "id": 101, "phone": "919800000000", "platform": "whatsapp" },
"to": { "phone": "+919876543210", "contact_id": 991 },
"text": "We open at 10 tomorrow.",
"media": null,
"send_at": null,
"expires_at": null,
"created_at": "2026-10-19T16:02:40.118Z",
"sent_at": null,
"error": null
}
]
}
Erros ao enviar um lote#
Os mesmos de quando você envia uma mensagem, com a posição do destinatário em field (to[0].phone), e também:
| HTTP | Código | Quando |
|---|---|---|
400 | too_many_recipients | to tem mais de 1.000 entradas |
400 | validation_failed | to está vazio ou não é uma lista |
402 | no_credits | A lista não cabe nas mensagens restantes: requested e remaining dizem em quanto |
429 | number_daily_cap_reached | A lista não cabe no espaço do número para as próximas 24 horas: requested e room dizem quantas |
Listar mensagens#
https://wbiztool.com/api/v2/messagesAs mensagens do espaço de trabalho dos últimos 90 dias, das mais recentes para as mais antigas, em páginas. Isso inclui as mensagens enviadas pelo painel, por campanhas, lembretes, integrações e pela API v1, não só as da API v2. Uma chave limitada a alguns números vê só as mensagens deles.
Parâmetros de consulta
numberintegeropcionalSó as mensagens deste
idde número.statusstringopcionalSó as mensagens com este status:
queued,scheduled,sent,delivered,read,failed,cancelledouexpired.sincestringopcionalSó as mensagens criadas neste horário ou depois dele (ISO 8601 com o deslocamento).
untilstringopcionalSó as mensagens criadas antes deste horário (ISO 8601 com o deslocamento).
limitintegeropcionalMensagens por página, de 1 a 100 (padrão 25).
cursorstringopcionalO
next_cursorda última página, com os mesmos filtros.
Os filtros se combinam. Em uma query string, escreva o + de um deslocamento como %2B (um + simples chega como espaço, que também é lido como +), ou use Z.
curl -G https://wbiztool.com/api/v2/messages \
-H "Authorization: Bearer $WBIZTOOL_API_KEY" \
--data-urlencode "status=failed" \
--data-urlencode "since=2026-10-01T00:00:00+05:30" \
--data-urlencode "limit=100"import os
import requests
headers = {"Authorization": f"Bearer {os.environ['WBIZTOOL_API_KEY']}"}
params = {"status": "failed", "since": "2026-10-01T00:00:00+05:30", "limit": 100}
failed = []
while True:
page = requests.get("https://wbiztool.com/api/v2/messages",
headers=headers, params=params, timeout=30).json()
failed += page["data"]
if not page["next_cursor"]:
break
params["cursor"] = page["next_cursor"] # same filters, next page
for message in failed:
print(message["id"], message["to"], message["error"]["message"])// Node.js 18+ (built-in fetch). Save as .mjs to use top-level await.
const params = new URLSearchParams({ status: "failed", since: "2026-10-01T00:00:00+05:30", limit: "100" });
const failed = [];
while (true) {
const response = await fetch(`https://wbiztool.com/api/v2/messages?${params}`, {
headers: { Authorization: `Bearer ${process.env.WBIZTOOL_API_KEY}` },
});
const page = await response.json();
failed.push(...page.data);
if (!page.next_cursor) break;
params.set("cursor", page.next_cursor); // same filters, next page
}
for (const message of failed) console.log(message.id, message.to, message.error.message);<?php
$params = ['status' => 'failed', 'since' => '2026-10-01T00:00:00+05:30', 'limit' => 100];
$failed = [];
do {
$ch = curl_init('https://wbiztool.com/api/v2/messages?' . http_build_query($params));
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WBIZTOOL_API_KEY')],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$page = json_decode(curl_exec($ch), true);
curl_close($ch);
$failed = array_merge($failed, $page['data']);
$params['cursor'] = $page['next_cursor']; // same filters, next page
} while ($page['next_cursor']);
foreach ($failed as $message) {
echo $message['id'] . ' ' . $message['error']['message'] . "\n";
}A resposta é {"data": [Message, …], "next_cursor": "…" or null}.
Erros ao listar#
| HTTP | Código | Quando |
|---|---|---|
400 | validation_failed | number não é um id de número, status não é um dos status ou limit não está entre 1 e 100 |
400 | invalid_datetime | since ou until não está em ISO 8601 com o deslocamento |
400 | invalid_cursor | O cursor está danificado ou foi criado com outros filtros |
Obter uma mensagem#
https://wbiztool.com/api/v2/messages/{id}Uma mensagem e o status atual dela.
curl https://wbiztool.com/api/v2/messages/9817263 \
-H "Authorization: Bearer $WBIZTOOL_API_KEY"import os
import requests
response = requests.get(
"https://wbiztool.com/api/v2/messages/9817263",
headers={"Authorization": f"Bearer {os.environ['WBIZTOOL_API_KEY']}"},
timeout=30,
)
message = response.json()
print(message["status"], message["sent_at"])// Node.js 18+ (built-in fetch). Save as .mjs to use top-level await.
const response = await fetch("https://wbiztool.com/api/v2/messages/9817263", {
headers: { Authorization: `Bearer ${process.env.WBIZTOOL_API_KEY}` },
});
const message = await response.json();
console.log(message.status, message.sent_at);<?php
$ch = curl_init('https://wbiztool.com/api/v2/messages/9817263');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WBIZTOOL_API_KEY')],
CURLOPT_RETURNTRANSFER => true,
]);
$message = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $message['status'];Resposta (200), uma mensagem que foi entregue:
{
"id": 9817263,
"status": "delivered",
"from": { "id": 101, "phone": "919800000000", "platform": "whatsapp" },
"to": { "phone": "+919876543210", "contact_id": 991 },
"text": "Hi Asha, your order #1042 is ready.",
"media": null,
"send_at": null,
"expires_at": null,
"created_at": "2026-10-18T10:15:02.311Z",
"sent_at": "2026-10-18T10:15:09.870Z",
"error": null
}
Uma mensagem que não existe, pertence a outro espaço de trabalho ou (para uma chave limitada a alguns números) foi enviada de outro número responde 404 message_not_found.
Cancelar uma mensagem#
https://wbiztool.com/api/v2/messages/{id}Cancela uma mensagem que ainda está em espera (queued ou scheduled), seja quem for que a criou. A resposta é 200 com a Message, agora no status cancelled. Um endpoint de webhook recebe message.cancelled.
Uma mensagem que já saiu não pode ser recolhida. Uma mensagem que o número que envia está pegando naquele exato momento ainda pode sair, então verifique o status dela depois, se isso for importante.
curl -X DELETE https://wbiztool.com/api/v2/messages/9817263 \
-H "Authorization: Bearer $WBIZTOOL_API_KEY"import os
import requests
response = requests.delete(
"https://wbiztool.com/api/v2/messages/9817263",
headers={"Authorization": f"Bearer {os.environ['WBIZTOOL_API_KEY']}"},
timeout=30,
)
body = response.json()
if response.status_code == 200:
print("Cancelled")
elif body["error"]["code"] == "not_cancellable":
print("Too late, it is", body["error"]["status"])// Node.js 18+ (built-in fetch). Save as .mjs to use top-level await.
const response = await fetch("https://wbiztool.com/api/v2/messages/9817263", {
method: "DELETE",
headers: { Authorization: `Bearer ${process.env.WBIZTOOL_API_KEY}` },
});
const body = await response.json();
if (response.ok) console.log("Cancelled");
else if (body.error.code === "not_cancellable") console.log("Too late, it is", body.error.status);<?php
$ch = curl_init('https://wbiztool.com/api/v2/messages/9817263');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WBIZTOOL_API_KEY')],
CURLOPT_RETURNTRANSFER => true,
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo $status === 200 ? 'Cancelled' : $body['error']['code'];Erros ao cancelar#
| HTTP | Código | Quando |
|---|---|---|
404 | message_not_found | Nenhuma mensagem assim para esta chave |
409 | not_cancellable | Ela não está mais em espera; status no erro diz como ela está agora, como sent |
O objeto Message#
Todas as operações desta página respondem com objetos Message, e os eventos de webhook trazem um em data.message.
| Campo | Tipo | Descrição |
|---|---|---|
id | integer | O id da mensagem. Use-o para obter ou cancelar a mensagem. |
status | string | Onde ela está agora. Veja Status. |
from | object | O número que envia: id, phone (os dígitos com o código do país, sem +) e platform (whatsapp ou telegram). |
to | object | O destinatário: phone (com +), group ou username, e contact_id, o contato salvo para o qual ela foi. Um número de telefone ou grupo que recebe uma mensagem pela primeira vez é salvo como contato. (contact_id só é null em uma mensagem de teste para um número que não está salvo.) |
text | string | O texto ou a legenda; "" quando não há nenhum. |
media | object or null | type (image ou file), url e file_name (null para imagens). A url pode ser um link que funciona por tempo limitado; não a guarde. |
send_at | string or null | O horário para o qual ela está agendada, ou null para "agora". |
expires_at | string or null | O horário depois do qual ela não é enviada, ou null. |
created_at | string | Quando ela foi criada. |
sent_at | string or null | Quando ela saiu, depois que sai (sent, delivered ou read). |
error | object or null | Em uma mensagem failed ou expired, message diz por quê, como File exceeds WhatsApp size limit (16MB max). null nos outros casos. |
Status#
| Status | Significado |
|---|---|
queued | Esperando para sair. |
scheduled | Esperando o seu send_at. |
sent | Enviada do seu número (um tique no WhatsApp). |
delivered | Entregue no celular do destinatário (dois tiques). |
read | Lida pelo destinatário (tiques azuis). |
failed | Não pôde ser enviada; error.message diz por quê. |
cancelled | Cancelada antes de sair: com Cancelar uma mensagem, pelo painel ou porque o número ou a campanha dela foi removido. |
expired | Não enviada antes do seu expires_at. |
queued e scheduled podem ser canceladas. sent ainda pode passar a delivered e depois a read. failed, cancelled, expired e read são finais. Os webhooks informam sent, failed, cancelled e expired; para ver delivered e read, obtenha a mensagem.
Destinatários#
to aceita exatamente um destes campos:
| Campo | Exemplo | Quem recebe |
|---|---|---|
phone | {"phone": "+919876543210"} | Um número de telefone no formato internacional com + (E.164). Espaços e hifens são permitidos. |
group | {"group": "Bakery staff"} | Um grupo de WhatsApp do qual o número que envia participa, pelo nome exato (até 200 caracteres). |
contact_id | {"contact_id": 991} | Um contato salvo no espaço de trabalho, pelo id (o contact_id de uma mensagem anterior, por exemplo). Ela vai para o número ou o grupo desse contato. |
username | {"username": "@asha_bakes"} | Um usuário do Telegram: @name, um link t.me/name, ou tg: e um id de usuário. Só a partir de uma conta do Telegram, então informe uma como from (ou tenha exatamente uma conectada). |
Envios para grupos e pelo Telegram exigem que o número que envia esteja nesse grupo ou consiga alcançar esse usuário; os problemas aí aparecem como uma mensagem failed, não como um erro na hora do envio.
Mídia#
media envia uma imagem ou um arquivo, com text como legenda. Informe exatamente um de url ou media_id:
media.urlstringurl ou media_idUm link público
httpouhttpspara o arquivo, com até 1.000 caracteres. Links para endereços privados ou locais são recusados cominvalid_media_url.media.media_idintegerurl ou media_idUm arquivo do Diretório de mídia do espaço de trabalho, obtido em Enviar um arquivo. Envie um arquivo uma vez e mande-o quantas vezes quiser.
media.typestringopcionalimageoufile. Se for omitido, um link que termina em.jpg,.jpeg,.png,.gifou.webpé enviado como imagem e todo o resto como arquivo; ummedia_idusa o tipo com que o arquivo foi enviado.media.file_namestringopcionalPara um
file: o nome que o destinatário vê, comoinvoice-1042.pdf(até 100 caracteres). Se for omitido, o nome vem do link ou do arquivo enviado. A extensão decide como o arquivo é enviado, então inclua uma. Ignorado para imagens.
{
"to": { "phone": "+919876543210" },
"text": "Your invoice for order #1042",
"media": { "url": "https://example.com/invoices/1042.pdf", "type": "file", "file_name": "invoice-1042.pdf" }
}
O arquivo é baixado quando a mensagem é enviada, não quando você chama a API, então um link quebrado ou um arquivo grande demais aparece como uma mensagem failed, com o motivo em error.message. As regras de tamanho e tipo são as da v1: veja Enviar imagens e arquivos.
Agendamento e expiração#
send_atagenda a mensagem. Ela ficascheduledaté lá e pode ser cancelada até sair. Escreva o horário com o deslocamento, então2026-10-20T09:30:00+05:30significa 09:30 na Índia. Para mensagens que se repetem, use lembretes.expires_até um prazo: se a mensagem não tiver saído até lá (o número estava desconectado, por exemplo), ela passa aexpiredem vez de sair atrasada. Use-o para códigos de uso único, horários de entrega e ofertas. Uma tarefa em segundo plano a marca como expirada pelo menos 30 segundos depois do prazo, então não conte com prazos menores que um minuto.- Os dois precisam estar no futuro, e
expires_atprecisa ser depois desend_at.
