API v2 (beta)
Mensajes
Envía un mensaje ahora o a una hora fijada, a un número de teléfono, un grupo de WhatsApp, un contacto guardado o un nombre de usuario de Telegram. Envía el mismo mensaje a hasta 1.000 destinatarios en una sola solicitud, lee el historial, sigue el estado de un mensaje y cancélalo mientras espera.
| Operación | Endpoint | Permiso |
|---|---|---|
| Enviar un mensaje | POST /api/v2/messages | messages:send |
| Enviar a muchos destinatarios | POST /api/v2/messages/batch | messages:send |
| Listar mensajes | GET /api/v2/messages | messages:read |
| Obtener un mensaje | GET /api/v2/messages/{id} | messages:read |
| Cancelar un mensaje | DELETE /api/v2/messages/{id} | messages:send |
Las claves, los errores, Idempotency-Key y los límites funcionan como se describe en la introducción a la API v2. Para saber cuándo se envía o falla un mensaje sin consultarlo una y otra vez, añade un endpoint de webhook.
Enviar un mensaje#
https://wbiztool.com/api/v2/messagesCuerpo: JSON (application/json)
El mensaje se pone en cola y la respuesta llega antes de que salga, con HTTP 202 y el Message en estado queued (o scheduled con un send_at futuro). Síguelo con Obtener un mensaje o con un 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'];
}Respuesta (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
}
Cuerpo
toobjectobligatorioQuién lo recibe: exactamente uno de
phone,group,contact_idousername. Consulta Destinatarios.frominteger or nullObligatorio salvo que el espacio de trabajo tenga un solo número conectadoEl
iddel número que lo envía, de Listar números. Si lo omites, lo envía el único número conectado: se usa el único número de WhatsApp conectado aunque también haya una cuenta de Telegram conectada, y una cuenta de Telegram solo cuando no hay ningún número de WhatsApp conectado. Se acepta un número con la sesión cerrada: sus mensajes esperan hasta que se vuelva a conectar.textstringObligatorio salvo que se envíen mediosEl mensaje, hasta 3.000 caracteres. Con
media, es el pie de foto y se puede omitir. El formato de WhatsApp funciona:*bold*,_italic_,~strikethrough~. En JSON, escribe los saltos de línea como\n.mediaobject or nullopcionalUna imagen o un archivo para enviar. Consulta Medios.
send_atstring or nullopcionalEnviar a esta hora en lugar de ahora: ISO 8601 con su desfase horario, como
2026-10-20T09:30:00+05:30. Debe estar en el futuro. Consulta Programación y caducidad.expires_atstring or nullopcionalNo enviar después de esta hora: en ese caso el mensaje pasa a
expired. ISO 8601 con su desfase horario, en el futuro y después desend_at.
Cabeceras
AuthorizationheaderobligatorioBearer wbz_live_…, una clave con el permisomessages:send.Idempotency-KeyheaderopcionalCualquier cadena única, para que un reintento nunca envíe un segundo mensaje. Consulta Idempotency-Key.
Errores al enviar#
| HTTP | Código | Cuándo |
|---|---|---|
400 | validation_failed | Falta un campo, tiene un tipo incorrecto o no se conoce; to no tiene ninguno de sus campos o tiene varios; media tiene a la vez url y media_id, o ninguno de los dos; expires_at no es posterior a send_at |
400 | text_required | No hay text ni media |
400 | text_too_long | text supera los 3.000 caracteres |
400 | invalid_datetime | send_at o expires_at no está en ISO 8601 con desfase horario |
402 | no_credits | Al espacio de trabajo no le quedan mensajes en su plan |
403 | number_not_allowed | La clave está limitada a otros números |
403 | account_disabled | La cuenta del propietario del espacio de trabajo está desactivada |
422 | invalid_phone | to.phone no es un número de teléfono en formato internacional con + |
422 | invalid_username | to.username no es un nombre de usuario de Telegram, un enlace ni un ID tg: |
422 | username_needs_telegram | to.username con un número de WhatsApp como from |
422 | contact_not_found | No hay ningún contacto con ese to.contact_id |
422 | media_not_found | No hay ningún archivo con ese media.media_id |
422 | invalid_media_url | media.url no es un enlace http(s) público |
422 | time_in_past | send_at o expires_at ya pasó |
422 | number_not_found | from no es un número de este espacio de trabajo |
422 | from_required | No hay from, y el espacio de trabajo no tiene ningún número conectado o tiene varios |
422 | message_rejected | El mensaje no se pudo poner en cola; message indica el motivo |
429 | number_daily_cap_reached | El número ya envió 1.000 mensajes en las últimas 24 horas |
Un envío rechazado no deja nada: no se guarda ningún mensaje, así que no hay nada que cancelar. Los errores que pueden ocurrir en cualquier solicitud (claves, permisos, límites de solicitudes, Idempotency-Key) están en la página de errores.
Enviar a muchos destinatarios#
https://wbiztool.com/api/v2/messages/batchCuerpo: JSON (application/json)
Envía el mismo mensaje a hasta 1.000 destinatarios. El cuerpo es el mismo que en Enviar un mensaje, salvo que to es una lista de destinatarios.
- Todo o nada. Se comprueban todos los destinatarios antes de poner nada en cola, y la lista completa debe caber en los mensajes que le quedan al plan y en el margen del número que envía para las próximas 24 horas. Si un destinatario es incorrecto, no se pone nada en cola, y
fieldlo indica, comoto[3].phone. - El mismo destinatario dos veces recibe un solo mensaje.
- La respuesta es
202condata: un Message por destinatario, en el orden 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'];
}Respuesta (202), un Message por destinatario; el segundo y el tercero son como el primero, con to igual a {"phone": "+447700900123", …} y {"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
}
]
}
Errores al enviar un lote#
Los mismos que al enviar un mensaje, con la posición del destinatario en field (to[0].phone), y además:
| HTTP | Código | Cuándo |
|---|---|---|
400 | too_many_recipients | to tiene más de 1.000 entradas |
400 | validation_failed | to está vacío o no es una lista |
402 | no_credits | La lista no cabe en los mensajes que quedan: requested y remaining indican cuánto falta |
429 | number_daily_cap_reached | La lista no cabe en el margen del número para las próximas 24 horas: requested y room indican cuántos |
Listar mensajes#
https://wbiztool.com/api/v2/messagesLos mensajes del espacio de trabajo de los últimos 90 días, primero los más recientes, en páginas. Incluye los mensajes enviados desde el panel, las campañas, los recordatorios, las integraciones y la API v1, no solo los de la API v2. Una clave limitada a algunos números solo ve los mensajes de esos números.
Parámetros de consulta
numberintegeropcionalSolo los mensajes de este
idde número.statusstringopcionalSolo los mensajes con este estado:
queued,scheduled,sent,delivered,read,failed,cancelledoexpired.sincestringopcionalSolo los mensajes creados a esta hora o después (ISO 8601 con desfase horario).
untilstringopcionalSolo los mensajes creados antes de esta hora (ISO 8601 con desfase horario).
limitintegeropcionalMensajes por página, de 1 a 100 (25 por defecto).
cursorstringopcionalEl
next_cursorde la última página, con los mismos filtros.
Los filtros se combinan. En una cadena de consulta, escribe el + de un desfase horario como %2B (un + sin codificar llega como un espacio, que también se lee como +), o usa 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";
}La respuesta es {"data": [Message, …], "next_cursor": "…" or null}.
Errores al listar#
| HTTP | Código | Cuándo |
|---|---|---|
400 | validation_failed | number no es un ID de número, status no es uno de los estados o limit no está entre 1 y 100 |
400 | invalid_datetime | since o until no está en ISO 8601 con desfase horario |
400 | invalid_cursor | El cursor está dañado o se creó con otros filtros |
Obtener un mensaje#
https://wbiztool.com/api/v2/messages/{id}Un mensaje y su estado actual.
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'];Respuesta (200), un mensaje que se entregó:
{
"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
}
Un mensaje que no existe, que pertenece a otro espacio de trabajo o que (para una clave limitada a algunos números) se envió desde otro número responde 404 message_not_found.
Cancelar un mensaje#
https://wbiztool.com/api/v2/messages/{id}Cancela un mensaje que todavía está en espera (queued o scheduled), lo haya creado quien lo haya creado. La respuesta es 200 con el Message, ahora en estado cancelled. Un endpoint de webhook recibe message.cancelled.
Un mensaje que ya salió no se puede recuperar. Un mensaje que el número que envía está recogiendo en ese momento aún puede salir, así que comprueba su estado después si es 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'];Errores al cancelar#
| HTTP | Código | Cuándo |
|---|---|---|
404 | message_not_found | No existe ese mensaje para esta clave |
409 | not_cancellable | Ya no está en espera; status en el error indica en qué estado está ahora, como sent |
El objeto Message#
Todas las operaciones de esta página responden con objetos Message, y los eventos de webhook llevan uno en data.message.
| Campo | Tipo | Descripción |
|---|---|---|
id | integer | El ID del mensaje. Úsalo para obtener o cancelar el mensaje. |
status | string | En qué punto está ahora. Consulta Estados. |
from | object | El número que envía: id, phone (sus dígitos con el código de país, sin +) y platform (whatsapp o telegram). |
to | object | El destinatario: phone (con +), group o username, y contact_id, el contacto guardado al que se envió. Un número de teléfono o un grupo al que se envía por primera vez se guarda como contacto. (contact_id solo es null para un mensaje de prueba a un número que no está guardado). |
text | string | El texto o el pie de foto; "" cuando no hay. |
media | object or null | type (image o file), url y file_name (null para imágenes). La url puede ser un enlace que solo funciona durante un tiempo limitado; no la guardes. |
send_at | string or null | La hora para la que está programado, o null para "ahora". |
expires_at | string or null | La hora a partir de la cual ya no se envía, o null. |
created_at | string | Cuándo se creó. |
sent_at | string or null | Cuándo salió, una vez que ha salido (sent, delivered o read). |
error | object or null | Para un mensaje failed o expired, message indica el motivo, como File exceeds WhatsApp size limit (16MB max). null en los demás casos. |
Estados#
| Estado | Significado |
|---|---|
queued | Esperando a salir. |
scheduled | Esperando a su send_at. |
sent | Enviado desde tu número (un tic en WhatsApp). |
delivered | Entregado en el teléfono del destinatario (dos tics). |
read | Leído por el destinatario (tics azules). |
failed | No se pudo enviar; error.message indica el motivo. |
cancelled | Cancelado antes de salir: con Cancelar un mensaje, desde el panel o porque se quitó su número o su campaña. |
expired | No se envió antes de su expires_at. |
queued y scheduled se pueden cancelar. sent aún puede pasar a delivered y después a read. failed, cancelled, expired y read son definitivos. Los webhooks informan de sent, failed, cancelled y expired; para ver delivered y read, obtén el mensaje.
Destinatarios#
to acepta exactamente uno de estos campos:
| Campo | Ejemplo | Quién lo recibe |
|---|---|---|
phone | {"phone": "+919876543210"} | Un número de teléfono en formato internacional con + (E.164). Se permiten espacios y guiones. |
group | {"group": "Bakery staff"} | Un grupo de WhatsApp en el que está el número que envía, por su nombre exacto (hasta 200 caracteres). |
contact_id | {"contact_id": 991} | Un contacto guardado en el espacio de trabajo, por su ID (por ejemplo, el contact_id de un mensaje anterior). Se envía al número o al grupo de ese contacto. |
username | {"username": "@asha_bakes"} | Un usuario de Telegram: @name, un enlace t.me/name, o tg: y un ID de usuario. Solo desde una cuenta de Telegram, así que indica una como from (o ten exactamente una conectada). |
Los envíos a grupos y a Telegram necesitan que el número que envía esté en ese grupo o pueda llegar a ese usuario; los problemas ahí aparecen como un mensaje failed, no como un error al enviar.
Medios#
media envía una imagen o un archivo, con text como pie de foto. Indica exactamente uno de url o media_id:
media.urlstringurl o media_idUn enlace público
httpohttpsal archivo, de hasta 1.000 caracteres. Los enlaces a direcciones privadas o locales se rechazan coninvalid_media_url.media.media_idintegerurl o media_idUn archivo del Directorio de medios del espacio de trabajo, de Subir un archivo. Sube un archivo una vez y envíalo tantas veces como quieras.
media.typestringopcionalimageofile. Si lo omites, un enlace que termina en.jpg,.jpeg,.png,.gifo.webpse envía como imagen y cualquier otro como archivo; unmedia_idusa el tipo con el que se subió el archivo.media.file_namestringopcionalPara un
file: el nombre que ve el destinatario, comoinvoice-1042.pdf(hasta 100 caracteres). Si lo omites, el nombre se toma del enlace o del archivo subido. Su extensión decide cómo se envía el archivo, así que incluye una. Se ignora para las imágenes.
{
"to": { "phone": "+919876543210" },
"text": "Your invoice for order #1042",
"media": { "url": "https://example.com/invoices/1042.pdf", "type": "file", "file_name": "invoice-1042.pdf" }
}
El archivo se descarga cuando se envía el mensaje, no cuando llamas a la API, así que un enlace roto o un archivo demasiado grande aparece como un mensaje failed con el motivo en error.message. Las reglas de tamaño y tipo son las de v1: consulta Enviar imágenes y archivos.
Programación y caducidad#
send_atprograma el mensaje. Queda enscheduledhasta entonces y se puede cancelar hasta que sale. Escribe la hora con su desfase horario, así2026-10-20T09:30:00+05:30significa las 09:30 en la India. Para mensajes que se repiten, usa recordatorios.expires_ates un plazo límite: si el mensaje no ha salido para entonces (por ejemplo, porque el número tenía la sesión cerrada), pasa aexpireden lugar de salir tarde. Úsalo para códigos de un solo uso, horarios de entrega y ofertas. Un proceso en segundo plano lo marca como caducado al menos 30 segundos después del plazo, así que no confíes en plazos de menos de un minuto.- Ambos deben estar en el futuro, y
expires_atdebe ser posterior asend_at.
