Your script scheduled a reminder for 9am and it landed at 4am. Or it landed twice. Or it never landed and there was nothing in your logs to explain why. None of those are bugs in the API. They are the five things that break when a working POST turns into a whatsapp api python schedule that has to run unattended, and every one of them has a specific fix.
What breaks when a Python WhatsApp schedule goes from a script to production
A whatsapp api python schedule fails in five predictable places once real traffic hits it, and each one is a section below. This article assumes your first scheduled POST already returns a 201; if it does not, read the WhatsApp API quickstart first, which covers key creation, recipient formatting and the first send end to end.
- The timestamp. Right on your laptop, wrong on a UTC server.
- The retry. A read timeout fired again and the recipient got two messages.
- The error handling. You retried a
400, which will never stop being a400. - The burst. You looped 300 sends and collected
429s instead of deliveries. - The silence. One message never arrived and nothing in your code knows why.
One framing note before the code. This whatsapp api python integration drives the WhatsApp number you already own, over WhatsApp Web or an always-on managed gateway linked as a device. It is not Meta's Cloud API, so no message templates, no Business verification and no per-conversation billing appear anywhere below. Consent is yours: WhatsApp's policy on unauthorized use of automated or bulk messaging applies no matter which tool sent the message, and nobody can promise you an account will not be actioned.
How to build a sendAt timestamp in Python that is actually correct
sendAt is validated as an RFC 3339 datetime that must carry an explicit Z or a UTC offset. A naive local-time string fails with 400 sendAt: Invalid datetime, so build the value with datetime.now(timezone.utc) (never utcnow()) and always serialize with .isoformat(). This is the field that breaks most often.
Three failures live in it.
Failure one: utcnow() is naive. datetime.utcnow() returns a datetime with tzinfo set to None. Calling .isoformat() on it produces 2026-07-28T09:00:00, with no offset, and the API rejects it. The Python docs are blunt about this: the module has deprecated utcnow() since 3.12 and states that "the recommended way to create an object representing the current time in UTC is by calling datetime.now(timezone.utc)" (datetime docs).
Failure two: str() instead of .isoformat(). This one is quiet. str(aware_dt) renders 2026-08-01 09:00:00+00:00 with a space where RFC 3339 wants a T. The offset is there, the value looks correct in your log line, and the API still returns 400 sendAt: Invalid datetime. An f-string that interpolates a datetime hits this by accident.
Failure three: DST. "Now plus 24 hours" is not "same time tomorrow." Adding a timedelta moves a fixed number of seconds; a wall clock does not. Take 9:00am on 2026-10-31 in America/New_York, add 24 hours to the UTC instant, and you land at 08:00 EST on November 1st, because US daylight time ends that morning per the IANA time zone database. An hour early on an appointment reminder is a missed appointment.
To schedule a WhatsApp message, Python has to construct the local wall-clock time first and convert second, using stdlib zoneinfo (3.9+, backed by the IANA database). On Windows there is no system tz database, so declare a dependency on tzdata.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def send_at_local(year, month, day, hour, minute, tz_name):
"""9am in the RECIPIENT's zone, serialized the way the API wants it."""
local = datetime(year, month, day, hour, minute, tzinfo=ZoneInfo(tz_name))
return local.astimezone(timezone.utc).isoformat()
send_at_local(2026, 11, 1, 9, 0, "America/New_York")
# '2026-11-01T14:00:00+00:00' <- correct, 9am EST
send_at_local(2026, 11, 1, 9, 0, "Asia/Kolkata")
# '2026-11-01T03:30:00+00:00'
Both Z and a numeric offset are accepted, with or without fractional seconds. The window is bounded at both ends: at least 10 seconds ahead, no more than 365 days out.
One rule that saves a migration later: store an IANA zone name per contact ("Europe/Berlin"), never a UTC offset. Offsets change twice a year. Zone names do not. If you are still weighing scheduling against sending immediately, the send-versus-schedule breakdown covers that choice.

How to make a scheduled send idempotent so a retry never double-sends
Pass an Idempotency-Key header derived from the business object, computed once, above the retry loop. Reusing the key with an identical body replays the original response, returning the same message id and creating no second message. Reusing it with a changed body returns 409.
The derivation rules (never a random UUID, always the thing that must produce exactly one message) are covered in full in the WhatsApp automation API guide, and the replay and conflict semantics in the own-number REST API reference. The angle those two do not cover is where the key sits relative to your retry code, which is the only place it can actually go wrong:
import hashlib
# Computed ONCE, from the business object. Not inside the loop.
idem_key = hashlib.sha256(f"booking:{booking_id}:reminder".encode()).hexdigest()
for attempt in range(4):
resp = session.post(
url,
headers={"Authorization": f"Bearer {API_KEY}", "Idempotency-Key": idem_key},
json=body,
timeout=(5, 30),
)
...
A key generated inside the loop, or refreshed per attempt, defeats the entire mechanism. Every retry then looks like a brand new request and sends a brand new message. A SHA-256 hex digest is exactly 64 characters, which is also the header's maximum length, so it fits with nothing to spare.
Two behaviours a Python caller has to handle:
- The status code does NOT change on replay. A first create is
201, and so is the replay of that same key. Assertingstatus_code == 201is safe, and the way to tell a replay from a fresh create is that the returned message id is the one you already have, not the status code. Do not branch on200here. - Keys expire. Stored records live 24 hours from creation, so a key reused a week later is a fresh request, not a replay.
One trap worth naming: the body field secret is a correlation tag, not a dedupe key. The API's own field description says so directly. Sending the same secret twice creates two messages.
How to retry a failed API call with exponential backoff, and which errors to never retry
Retry connection errors, read timeouts, 5xx and 429. Never retry 400, 401, 403, 404, 409, 410 or 422, because none of them will resolve on a second attempt. Add jitter to the backoff, cap total attempts, and always set an explicit timeout.
| Response | Retry? | Why |
|---|---|---|
| Connection error / read timeout | Yes | The request may never have reached the server |
500, 502, 503, 504 | Yes | Transient server-side |
429 rate_limited | Yes, after your own backoff | You are early, not wrong |
400 invalid_request | No | The body is malformed and will stay malformed |
401 authentication_required | No | Bad or missing key |
403 permission_denied | No | The key lacks the messages:write scope |
404 not_found | No | Wrong id, or nothing pending under it |
409 | No | Idempotency key conflict; retrying makes it worse |
410 | No | You posted to the collection path with no recipient |
The error codes in that table are the six locked strings on the /v1 surface (invalid_request, authentication_required, permission_denied, not_found, rate_limited, internal_error), and every error body carries them as error.code.
One implementation, using requests with urllib3's Retry so you add no dependency:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry = Retry(
total=4,
backoff_factor=0.5,
backoff_jitter=0.3, # urllib3 2.x
backoff_max=30,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({"GET", "POST"}), # POST is NOT retried by default
respect_retry_after_header=True,
raise_on_status=False,
)
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry))
Two things there deserve a second look. allowed_methods replaced the old method_whitelist argument in urllib3 2.0, and POST is deliberately absent from the default set, which for this library is HEAD, GET, PUT, DELETE, OPTIONS, TRACE. You have to opt it in by name. See the urllib3 Retry reference for the current signature.
Jitter matters because a fleet of workers that all fail at the same second will all retry at the same second, rebuilding the exact burst that broke them. backoff_jitter spreads them out. respect_retry_after_header=True makes urllib3 sleep for a server-stated interval when one is present, the behaviour RFC 9110 defines for Retry-After. Leave it on for portability, but do not count on it here: as the rate-limit section below shows, this API sends no Retry-After, so your backoff_factor is what actually paces the retry. If you prefer decorators, tenacity does the same job with @retry.
Set the timeout as a tuple, timeout=(5, 30), so a connect stall and a slow read fail separately. And note the ordering: retrying a POST at all is only safe because the idempotency key makes the duplicate a no-op.
WhatsApp API rate limits: the two separate ceilings, and how to pace a batch
There is not one whatsapp api rate limit, there are two independent ones. A per-API-key bucket on the send endpoint allows 60 requests per 60 seconds. Separately, a plan-level quota gives free accounts 5 API requests per 6-hour UTC window across the whole /v1 surface, while any active subscription is unlimited.
Be clear about what that second number means, because it is the one that surprises people. The free tier's API quota is an evaluation quota, not a production one. Five requests, in a fixed 6-hour window (four windows a day, aligned to 00:00 / 06:00 / 12:00 / 18:00 UTC), counted across every /v1 call you make, including reads. Two sends and three status checks exhausts it. It will 429 a production cron job on day one. An active subscription removes it entirely.
Keep that separate from the product's plan features, which are a different thing: the Free plan can send bulk campaigns from the app (they carry Blueticks branding, and Pro removes it). The API request quota is not the campaign feature.
When you do hit a 429, read the response rather than guessing:
if resp.status_code == 429:
body = resp.json()["error"]
# The plan quota puts the wait in the body. The per-key bucket gives you nothing,
# so fall back to a fixed backoff rather than reading a header that is not there.
wait = body.get("upgrade", {}).get("seconds_remaining")
time.sleep(int(wait or 60))
The gotcha, and it is worth knowing before you write the handler: the 429 carries no rate-limit headers at all. No Retry-After, no X-RateLimit-Limit, no X-RateLimit-Remaining, confirmed against a forced 429 on a live key. The only machine-readable wait signal the API emits is error.upgrade.seconds_remaining, and that appears on the plan-quota 429 only. Hit the per-key bucket and you get a bare 429 with nothing to tell you how long to wait, so your client needs its own fixed backoff. A 200 carries no budget information either, so you cannot pre-emptively pace from a successful response. You find the ceiling by hitting it.
For pacing a batch, a small sleep between sends or a bounded worker pool is enough, and there is no verified "safe" messages-per-hour number to give you. But looping the send endpoint is the wrong shape for one-to-many work in the first place. As of July 2026 there is no bulk or batch send endpoint on /v1: the supported mechanism is POST /v1/audiences plus POST /v1/campaigns, which the own-number REST API guide walks through.
Hitting the evaluation quota already? Get an API key and go unlimited. Timezone-correct, idempotent scheduled sends from your own WhatsApp number, with no Meta Business verification and no per-message fees.

How to see what is actually queued: listing and reconciling scheduled messages
The whatsapp scheduled message api lists the queue at GET /v1/scheduled-messages, paging with limit (1 to 200, default 50) and skip (default 0), not page or cursor. Optional filters are status, chatId, searchToken and order (asc or desc, default desc). The pagination counters are siblings of data, not nested inside it.
A successful list body looks like this:
{ "success": true, "data": [ … ], "limit": 50, "skip": 0, "total": 412 }
That flat shape is what breaks a first attempt: body["data"]["total"] raises, body["total"] works. Here is the reusable generator:
def iter_scheduled(session, api_key, **filters):
"""Page /v1/scheduled-messages until the offset passes `total`."""
skip, limit = 0, 200
while True:
r = session.get(
"https://api.blueticks.co/v1/scheduled-messages",
headers={"Authorization": f"Bearer {api_key}"},
params={"limit": limit, "skip": skip, **filters},
timeout=(5, 30),
)
r.raise_for_status()
body = r.json()
yield from body["data"]
skip += limit
if skip >= body["total"]:
return
Notice what that loop does not do: it never breaks on an empty page. status=pending is applied before pagination, so total is exact. Every other status value is derived from the delivery log and filtered after the page is cut, which means a filtered page can come back short or completely empty while later pages still hold matches, and total reflects the pre-filter count. Break on an empty page and you will silently truncate your own reconciliation.
Two more asymmetries to plan for. The chatId filter takes a WhatsApp JID (15551234567@c.us), not the E.164 string the send path accepts in its URL. And cancel and edit live elsewhere: POST /v1/scheduled-messages/{id}/cancel is named in the quickstart, and the PATCH edit path in the own-number reference.
The reconciliation itself is the production point. Your database thinks it queued 400 reminders. The queue is the only source of truth for what is actually pending. Diff the two nightly and alert on the delta, because a message that never got queued produces no error anywhere.
How to diagnose a scheduled message that never arrived
Work the runbook in order. Fetch the message, read status, then failureReason, then check whether waMessageKey is present, then check whether an engine is even connected. Each step tells you which half of the pipeline failed, and you stop at the first one that answers.
- Fetch it.
GET /v1/scheduled-messages/{id}, thendata = resp.json()["data"](every single-object2xxbody is wrapped as{"success": true, "data": {...}}) and readdata["status"]first. If it still sayspendingpast itssendAt, nothing dispatched. - Read the failure reason. If
datacarries afailureReason, that string is the answer and there is nothing further to do in code. - Check for the wire key. This is the discriminator.
waMessageKeypresent means WhatsApp accepted the message and the problem is downstream delivery. Absent means it never reached WhatsApp at all. Empty values are stripped from every2xxbody, so an absent key is a missing dictionary entry, not aNonevalue: writeif "waMessageKey" not in data:ordata.get("waMessageKey"), neverdata["waMessageKey"] is None, which raises aKeyError. - Check the session. If nothing dispatched, the cause is usually not your code.
GET /v1/enginesreturns one entry per connected engine withconnected,stateandhasSynced. An emptydataarray means no engine is paired, and a scheduled message cannot dispatch through a disconnected session. - Quote the request id. Every response, success and error, carries an
X-Request-Idheader, echoed into error bodies aserror.requestId. Log it on every call. It is the single fastest thing to paste into a support ticket.
Two behaviours worth knowing before you go hunting. A cancelled message does not report a cancelled status: the cancel endpoint returns status: "failed" with failureReason: "cancelled by user", and the queue row is soft-deleted, so a later GET on that id returns 404. And the full status ladder and its tick mapping are documented in the quickstart. At any real volume, stop polling and subscribe to webhooks instead.

Do you still need cron or APScheduler if the API schedules server-side?
For a one-shot send at a known future time, you need nothing. Set sendAt, the API holds the message, and building a dispatcher that wakes up to fire it is the most common over-engineering in this space. You still want a small python whatsapp scheduler for recurrence, conditional rules, and sends anchored to a business object that can move.
The anti-pattern is a long-running process that holds pending messages in memory and sends them when the clock hits. If it dies, the messages die with it. Pushing the wait server-side is the entire point.
Three cases genuinely still need a local scheduler:
- Recurrence.
sendAttakes one absolute instant. It does not accept a cron expression or a repeat rule, so a weekly reminder is recomputed and re-posted each cycle. - Conditional rules. "Every weekday at 9am local, skip public holidays" is your business logic, not a timestamp.
- Moving anchors. "24 hours before the booking" changes when the booking is rescheduled.
The correct shape is a short job that computes the next sendAt and calls the API, then exits. With APScheduler (3.11 at time of writing):
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
from zoneinfo import ZoneInfo
sched = BlockingScheduler(timezone=ZoneInfo("UTC"))
@sched.scheduled_job(CronTrigger(hour=2, minute=0))
def queue_tomorrows_reminders():
for booking in bookings_starting_tomorrow():
schedule_message(
chat_id=booking.phone,
text=f"Reminder: {booking.service} tomorrow at {booking.local_time}.",
local_dt=booking.reminder_local_dt,
tz_name=booking.timezone, # IANA name, stored per contact
business_key=f"booking:{booking.id}:reminder",
)
That job runs for a few seconds a night. The API holds the messages for the other 23 hours and 59 minutes. When an anchoring object moves, cancel the queued message and create a new one with a new key, a pattern the send-and-schedule guide covers in more depth.
Putting it together: one production sender
The whole whatsapp api python schedule collapses into one function: a UTC-converted timestamp built from the recipient's zone, an idempotency key derived from the business object and computed outside the retry path, bounded backoff with the right taxonomy, and an explicit timeout. Here is the whole schedule whatsapp message python path in one block, and it is the one worth copying.
import hashlib
import requests
from datetime import timezone
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from zoneinfo import ZoneInfo
BASE, API_KEY = "https://api.blueticks.co", "bt_live_YOUR_KEY"
NEVER_RETRY = {400, 401, 403, 404, 409, 410, 422}
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=Retry(
total=4, backoff_factor=0.5, backoff_jitter=0.3, backoff_max=30,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({"GET", "POST"}),
respect_retry_after_header=True, raise_on_status=False,
)))
def schedule_message(chat_id, text, local_dt, tz_name, business_key):
"""Queue one message. Calling again with the same business_key is a no-op.
local_dt: a NAIVE datetime holding the recipient's wall-clock time.
tz_name: an IANA zone name, e.g. "America/New_York".
"""
send_at = local_dt.replace(tzinfo=ZoneInfo(tz_name)).astimezone(timezone.utc)
resp = session.post(
f"{BASE}/v1/scheduled-messages/{chat_id}",
headers={
"Authorization": f"Bearer {API_KEY}",
"Idempotency-Key": hashlib.sha256(business_key.encode()).hexdigest(),
},
json={"type": "text", "text": text, "sendAt": send_at.isoformat()},
timeout=(5, 30),
)
if resp.status_code in NEVER_RETRY:
raise RuntimeError(
f"{resp.status_code} {resp.json()['error']['message']} "
f"req={resp.headers.get('x-request-id')}"
)
resp.raise_for_status()
return resp.json()["data"] # 201 on create AND on idempotent replay
Look at what you did not have to build: a scheduler process, a durable queue, a dispatch worker, a dead-letter path, and a Meta Business verification. What you got instead is a message that arrives from the number your recipient already has saved, which is the whole reason this shape exists rather than a Cloud API alternative.
One honest caveat before you wire it up: the free tier's 5 requests per 6-hour window is an evaluation quota. It is enough to prove the integration works and not enough to run it. Budget for a subscription before the cron job goes live, not after it starts throwing 429s.
FAQ
How do I schedule a WhatsApp message in Python?
To schedule a WhatsApp message, Python posts to https://api.blueticks.co/v1/scheduled-messages/{chatId} with an Authorization: Bearer header and a body of {"type": "text", "text": "...", "sendAt": "..."}. The recipient is a path segment, not a body field. sendAt must be an RFC 3339 datetime with an explicit offset, at least 10 seconds ahead and within 365 days.
Why does my sendAt timestamp get rejected?
Almost always a missing offset. datetime.utcnow().isoformat() produces a naive string and returns 400 sendAt: Invalid datetime. So does str(dt), which uses a space separator instead of a T. Use datetime.now(timezone.utc) (or a ZoneInfo-aware datetime) and serialize with .isoformat().
How do I stop a retry from sending the same WhatsApp message twice?
Send an Idempotency-Key header derived from the business object, computed once before your retry loop and reused on every attempt. An identical body replays the original response: same 201, same message id, no second message. A different body under the same key returns 409. Keys are workspace-scoped, max 64 characters, and stored for 24 hours.
Which WhatsApp API errors should I retry?
Connection errors, read timeouts, 5xx and 429. Never 400, 401, 403, 404, 409, 410 or 422. A 409 in particular means an idempotency key conflict, and retrying compounds it. Use status_forcelist=(429, 500, 502, 503, 504) and opt POST in via allowed_methods, since urllib3 excludes it by default.
What happens when I hit the rate limit?
You get 429 with error.code of rate_limited. The per-key send bucket allows 60 requests per 60 seconds and returns a bare 429 with no rate-limit headers (no Retry-After, no X-RateLimit-*), so your client needs its own fixed backoff. The free plan quota (5 requests per 6-hour UTC window) is the only one that tells you how long to wait, as error.upgrade.seconds_remaining in the body. An active subscription removes the plan quota.
Do I need cron or APScheduler if the API schedules for me?
Not for a one-shot future send. Set sendAt and let the API hold it. You still want a nightly job for recurrence (sendAt takes one absolute instant, not a cron expression), for conditional rules, and for reminders anchored to objects that can move. Compute the next send time and post it, rather than holding messages in a long-running process.
How do I list everything I have scheduled?
GET /v1/scheduled-messages with limit (max 200, default 50) and skip. The counters limit, skip and total are siblings of data in the response, not nested inside it. Filter with status, chatId (a WhatsApp JID, not E.164), searchToken and order. Page on the offset, not on an empty page.
Why did my scheduled message never send?
Fetch it by id and read status, then failureReason. If waMessageKey is absent from the response, it never reached WhatsApp; if present, the problem is downstream delivery. Empty values are stripped from responses, so test for a missing key rather than None. Then check GET /v1/engines: a disconnected session cannot dispatch anything.



