Send a WhatsApp message with Python
This page sends a WhatsApp message from Python with the requests library, then receives the reply in a small Flask endpoint. The first message goes to your own phone, so you see it arrive.
What you need
- An account on Threadwire.
- A linked number. In your panel, click "Add instance" and scan the QR code with WhatsApp on the phone (Settings → Linked devices). Then copy the Instance ID from the instance's page.
- An API key. Make it under Developers → API keys → New key (admins only).
- Python with
requests. The sample reads the key from an environment variable,THREADWIRE_KEY. The webhook endpoint further down also uses Flask.
An instance is one WhatsApp number linked to Threadwire. It is linked as a device, the same way WhatsApp Web is linked, so this is not Meta's official API. WhatsApp API without Meta approval says what that means.
Send the message
Send the first message to yourself. Write "hi" to your linked number from your own WhatsApp on another phone. The "Chat link" on the Instances page opens that chat. Then send to that phone:
# Python, with requests
import os, requests
response = requests.post(
'https://threadwire.net/api/v1/messages',
headers={'Authorization': f"Bearer {os.environ['THREADWIRE_KEY']}", 'Accept': 'application/json'},
json={'instance_id': 'YOUR_INSTANCE_ID', 'to': 'YOUR_PHONE', 'text': 'Hello from Threadwire'},
)
response.raise_for_status()
message = response.json()['data']
print(message['id'], message['status'])
headers: your API key as a bearer token inAuthorization, sent on every request.json: the fields of the message.requestssends them as a JSON body.instance_id: the Instance ID you copied.to: that phone's number in international format, like201012345678: country code, no+needed, no leading 0.text: the message, up to 4,096 characters.
Read the answer
The answer comes at once, and response.json()['data'] is the message:
202 {"data": {"id": "01K…", "status": "queued", "phone": "201012345678", "body": "Hello from Threadwire", "…": "…"}}
id: keep it.GET /v1/messages/{id}returns the message with its status at that moment.status:queuedmeans the message was accepted and waits its turn. It has not reached the phone yet.phoneandbody: who the message is for, and its text.
A queued message goes out the way a person would send it. Its status then moves to sent, delivered and read, or to failed with the reason in error.
You wrote first, so this message is a reply, and it arrives in seconds. A script that writes to people who never wrote to your number is a different case. Those first messages are spaced out, capped per day and not sent at night. How sending protects your number has the figures.
Receive the reply
Under Developers → Webhooks (admins only), set your endpoint's URL: a public https:// address. Every reply and status change is posted there as JSON:
{"id": "evt_01K…", "type": "message.received", "created_at": "2026-10-01T10:00:00+00:00",
"data": {"id": "01K…", "instance_id": "01K…", "phone": "201012345678", "body": "Hi!", "…": "…"}}
A reply arrives as message.received, and a delivery update as message.status.
Every event is signed per the Standard Webhooks spec. The signing secret starts with whsec_, and is shown when you save the URL. This function, from the webhooks guide, checks the signature with Python's standard library:
# Python: headers as lower-case names, raw_body as bytes (in Flask, request.get_data()).
import base64, hashlib, hmac, time
def is_genuine(headers: dict, raw_body: bytes, secret: str) -> bool:
# Reject old or future timestamps: a replayed request is not a new event.
if abs(time.time() - int(headers['webhook-timestamp'])) > 300:
return False
key = base64.b64decode(secret.removeprefix('whsec_'))
signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + raw_body
expected = 'v1,' + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(expected.encode(), signature.encode()) for signature in headers.get('webhook-signature', '').split(' '))
It needs Python 3.9 or later, for removeprefix(). Give it the raw body as bytes, before any JSON parsing. In Flask that is request.get_data():
# A Flask endpoint that uses is_genuine() from above.
import json, os
from flask import Flask, request
app = Flask(__name__)
@app.post('/webhooks/whatsapp')
def whatsapp():
headers = {name.lower(): value for name, value in request.headers.items()}
raw_body = request.get_data()
try:
genuine = is_genuine(headers, raw_body, os.environ['THREADWIRE_WEBHOOK_SECRET'])
except (KeyError, ValueError):
genuine = False # a header is missing, or the timestamp is not a number
if not genuine:
return '', 401
event = json.loads(raw_body)
if event['type'] == 'message.received':
print(f"{event['data']['phone']}: {event['data']['body']}")
return '', 200
Answer with any 2xx within 15 seconds. An event sent again keeps its webhook-id, so use that id to drop repeats.
Good to know
- No public URL for your script? Fetch the same events with
GET /v1/eventsinstead. See polling. - Retries are safe with an
Idempotency-Keyheader. Add'Idempotency-Key': 'order-1042-shipped'toheaders: any unique string of yours. A retry with the same key returns the first message instead of sending a second. raise_for_status()raises on a refused request. The answer has amessagein plain words and a stablecode, such asvalidation_failed. Read both fromerror.response.json()when you catchrequests.HTTPError.- Never send a failed message again in a loop. A message that cannot go is answered
202and later becomesfailed. Read itserrorand act on it. See what to retry.
Next
- Sending messages: files, voice notes, polls and the rest, as fields on the same request.
- Webhooks and events: every event, retries, and polling.
- The API reference: every endpoint, and the errors.
- Tools and code samples: the Postman collection, and this request in other languages.