How to post to a channel in Python with the Wapito WhatsApp API

Send to the channel id like any recipient, upload media once and reuse it, prefer link posts for traffic, and confirm from the status event.

A script, a token and an HTTP client is the whole stack. Everything below runs from a file you can execute today, and the same code moves to a worker or a container without changing shape.

Publishing to a Channel from Python uses the ordinary message endpoints with the newsletter id as the recipient, so the code is three requests.post calls that differ only in their payload dict. The Python that earns its keep is a scheduler that spaces posts out, a media upload cached by id, and a FastAPI route that records the status event as the publish confirmation.

Before you start

  • pip install requests
  • os.environ["WAPITO_TOKEN"]
  • A channel with a WhatsApp number linked to it, and its API token. Create one in the dashboard — the authentication guide shows where the token goes.

How it works

  1. Publish a text post

    There is no dedicated publish endpoint: you send to the channel id exactly as you would to a person. Keep posts self-contained, because followers cannot ask a follow-up question in the thread.

    import requests
    
    url = "https://api.wapito.com/v1/messages/text"
    
    payload = {
        "to": "+15551234567",
        "body": "Your order #4182 has shipped. Track it here: https://acme.example/t/4182",
        "typing_time": 3
    }
    headers = {
        "Authorization": "Bearer wpt_YOUR_TOKEN",
        "Content-Type": "application/json"
    }
    
    response = requests.post(url, json=payload, headers=headers)
    
    print(response.json())

    In Python publish text with requests.post(url, json={"to": newsletter_id, "text": post}) and keep response.json()["id"]; that id is what the status event will reference. Write the post in a triple-quoted string or load it from a file so line breaks survive, since a follower reads it as the whole message.

    API reference for this step
  2. Post an image with a caption

    Upload once and reuse the media id if the same asset goes to several channels. The caption is the post, so write it as the whole message rather than as a label for the picture.

    import requests
    
    url = "https://api.wapito.com/v1/messages/image"
    
    payload = {
        "to": "+15551234567",
        "media": "https://acme.example/labels/4182.png",
        "caption": "Your shipping label for order #4182"
    }
    headers = {
        "Authorization": "Bearer wpt_YOUR_TOKEN",
        "Content-Type": "application/json"
    }
    
    response = requests.post(url, json=payload, headers=headers)
    
    print(response.json())

    In Python an image post is the same call shape with the media id and a caption in the json= dict; upload the file once, store the id in a variable, and reuse it if the asset goes to several channels. The caption is the post, so put the full sentence there rather than a label.

    API reference for this step
  3. Share a link with a preview

    A link post with a preview is the highest-performing format for driving people off WhatsApp, which is usually the point of a channel. Put the destination on a URL you control so you can measure it.

    import requests
    
    url = "https://api.wapito.com/v1/messages/link"
    
    payload = {
        "to": "+15551234567",
        "url": "https://acme.example/t/4182",
        "title": "Track order #4182",
        "description": "Out for delivery, arriving before 18:00.",
        "image": "https://acme.example/og/tracking.png",
        "body": "Your parcel is on the van:"
    }
    headers = {
        "Authorization": "Bearer wpt_YOUR_TOKEN",
        "Content-Type": "application/json"
    }
    
    response = requests.post(url, json=payload, headers=headers)
    
    print(response.json())

    In Python a link post is requests.post on /messages/link with the URL and preview text in the dict; use urllib.parse.urlencode to append the campaign parameters to a URL on your own domain so the click is measurable. The preview is generated from the target page, so the target needs proper meta tags.

    API reference for this step
  4. Confirm the post landed

    Channel posts produce their own status events. Use them to confirm publication and to key your own record of what went out and when, rather than trusting the send response alone.

    Arrives on your webhook as messages.status.

    In Python the FastAPI handler receives messages.status for the channel post; match payload["data"]["id"] against the ids you stored and mark the post published with the timestamp from the event. Do the update in a background task and return the acknowledgement at once so retries never fire.

The whole script

Every step above in one runnable file. Save it as post-to-channel.py, put your token in the environment, and run it.

"""Post to Channel with the Wapito WhatsApp API.

Send to the channel id like any recipient, upload media once and reuse it, prefer link posts for traffic, and confirm from the status event.

Run it with:
    export WAPITO_TOKEN="wpt_..."
    python post-to-channel.py
"""
import os

import requests

BASE_URL = "https://api.wapito.com/v1"
TOKEN = os.environ["WAPITO_TOKEN"]

# --- Publish a text post ---
url = BASE_URL + "/messages/text"

payload = {
    "to": "+15551234567",
    "body": "Your order #4182 has shipped. Track it here: https://acme.example/t/4182",
    "typing_time": 3
}
headers = {
    "Authorization": "Bearer " + TOKEN,
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())

# --- Post an image with a caption ---
url = BASE_URL + "/messages/image"

payload = {
    "to": "+15551234567",
    "media": "https://acme.example/labels/4182.png",
    "caption": "Your shipping label for order #4182"
}
headers = {
    "Authorization": "Bearer " + TOKEN,
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())

# --- Share a link with a preview ---
url = BASE_URL + "/messages/link"

payload = {
    "to": "+15551234567",
    "url": "https://acme.example/t/4182",
    "title": "Track order #4182",
    "description": "Out for delivery, arriving before 18:00.",
    "image": "https://acme.example/og/tracking.png",
    "body": "Your parcel is on the van:"
}
headers = {
    "Authorization": "Bearer " + TOKEN,
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())

Receive the webhook

FastAPI receiver for messages, messages.status — it verifies the signature, answers immediately and does the work after. Install it with pip install fastapi uvicorn and save it as webhook.py.

import hashlib
import hmac
import os
import time

from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()
SECRET = os.environ["WAPITO_WEBHOOK_SECRET"].encode()
EVENTS = ["messages", "messages.status"]


def verify(raw: bytes, header: str | None) -> bool:
    """Checks the X-Wapito-Signature header: t=<ms>,v1=<hex hmac-sha256>."""
    if not header:
        return False
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    ts, sig = parts.get("t"), parts.get("v1")
    if not ts or not sig:
        return False
    if abs(time.time() * 1000 - int(ts)) > 300_000:  # 5 minute clock skew
        return False
    expected = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)


@app.post("/wapito")
async def wapito(request: Request, signature: str | None = Header(default=None, alias="X-Wapito-Signature")):
    raw = await request.body()
    if not verify(raw, signature):
        raise HTTPException(status_code=401, detail="bad signature")
    payload = await request.json()
    if payload["event"] in EVENTS:
        print(payload["event"], payload["data"])
    return {"ok": True}  # answer 2xx fast; do the real work in a queue
Every event, its payload and the retry rules

Errors you may hit

Gotchas

  • requests does not raise on a 4xx by itself. Call response.raise_for_status() or check response.ok, otherwise a 429 send_rate_limited silently looks like a successful send and your loop keeps hammering the queue.
  • json= and data= are not interchangeable. Pass json={...} so requests sets Content-Type: application/json and encodes UTF-8 for you; data= with a dict sends form encoding and the API answers 400 invalid_request.
  • The default requests timeout is None, which means a stalled connection hangs your worker forever. Always pass timeout=(5, 30) and treat a read timeout as "maybe delivered" - re-check with the message id before you send again.
  • A group id such as 120363000000000000@g.us is a string, not a number. Building it with an f-string from an int drops the precision and you get 404 not_found.
  • Emoji and accented text need no extra work in Python 3, but reading a CSV of numbers with the default encoding on Windows will mangle them - open files with encoding="utf-8".

Where to run it

  • Google Cloud Run (container, scale to zero, one revision per deploy)
  • Render background worker for the sender, Render web service for the webhook receiver
  • A 1 GB VPS with systemd for the worker and Caddy in front of the FastAPI receiver
  • AWS Lambda + API Gateway for the webhook receiver only (the sender needs a long-lived process for queue spacing)

Pitfalls in Python

  • Scheduling with time.sleep inside a long-running loop drifts as each request takes time; compute the next post time from a datetime and sleep until it, or use APScheduler with a cron trigger.
  • A str with Windows line endings read from a file arrives with carriage returns in the post; open the file with newline=None or strip the \r characters before sending.
  • Reusing one media id across channels is fine, but holding it in a module-level variable across a process restart is not; media ids expire, so store the upload date and re-upload after the retention window.

Frequently asked questions

Is there a separate endpoint for channel posts?

No. The channel id is simply another recipient form accepted by the ordinary send endpoints, which means your publishing code is the same code that sends messages. The only difference is the id you address and the fact that nobody can reply to what you post.

Can I schedule posts in advance?

Not inside WhatsApp - there is no scheduled post object. Schedule it on your side with a job runner and call the send endpoint at the moment you want it published. That also means your scheduler, rather than WhatsApp, owns the retry behaviour if a post fails.

Can I edit or delete a post after publishing?

Editing and deleting follow the same rules as ordinary messages, so both are possible within WhatsApp's own time window and only for posts the linked number sent. Beyond that window the post stands, which is a good reason to have a human approve anything automated before it goes out.

Related

Try it on your own number

Create a channel, link a WhatsApp number by QR or pairing code, and call the API in a couple of minutes. The Sandbox plan is free and needs no card.