To send an iMessage with Python, you make one HTTPS request to the Blueping API. Apple has no public iMessage API, and Blueping is an independent one for developers, not affiliated with Apple: your code sends JSON, and the Blueping line delivers it as an iMessage. Because your script only makes ordinary HTTPS requests, it works the same on Linux, Windows or macOS.
This guide covers the Python side with requests and Flask. You will get an API key from your iPhone, send yourself a message, invite someone with your join link, read their replies by polling and by webhook, handle the errors worth handling, and finish with a small echo bot.
Get an API key in a minute
Open https://blueping.pages.dev/start. The page shows a 6-character code: text it from your iPhone to the Blueping line, and the page then shows your API key, which starts with bp_live_. There is no password, no credit card and no sales call. The iPhone you texted from becomes a contact named me, so you can message yourself right away. When you need to sign in again later, text LOGIN to the line and it texts back a one-time link.
Install the two libraries this guide uses, and keep the key in an environment variable rather than in your code:
pip install requests flask
export BLUEPING_API_KEY="bp_live_..."Treat the key like a password and keep it out of git. If it ever leaks, POST /v1/keys replaces it with a new one.
Send an iMessage to yourself with Python
Every request goes to https://blueping.pages.dev/v1 with the key in an Authorization: Bearer header. Start with me, the contact your account has from the moment you sign up:
import os
import requests
response = requests.post(
"https://blueping.pages.dev/v1/messages",
headers={"Authorization": f"Bearer {os.environ['BLUEPING_API_KEY']}"},
json={"to": "me", "text": "Hello from Python"},
timeout=15,
)
print(response.status_code, response.json())The text lands on your iPhone as an ordinary iMessage, and the API answers 202 with the message it sent:
{
"id": "msg_...",
"status": "sent",
"to": {"id": "ct_...", "handle": "+15551234567"},
"created_at": "2026-11-04T17:00:00.000Z"
}text accepts up to 2,000 characters, and API v1 sends text only. To make retries safe after a network error, send each message with an Idempotency-Key header that holds a unique value. For 24 hours, another request with the same key returns the first answer, marked with the header idempotent-replayed: true, instead of sending the message again.
Why people have to text you first
Apart from me, your app can only message people who texted its code. The free line is shared between apps, and a person picks one by texting its code: that text is their opt-in, and it tells Blueping which app their messages belong to. People talk to one app at a time. Texting another app's code switches to it, LEAVE leaves the current app, MENU lists the apps they joined, HELP explains how it works, and STOP unsubscribes them from every app on the line until they text a code again.
Blueping answers those keywords itself, so your code never has to. If someone asks your app to stop in their own words, report it with POST /v1/contacts/{id}/opt-out, which unsubscribes them from every app.
Invite someone with your join link
Your account has a join code and a join link of the form https://blueping.pages.dev/j/CODE. Send the link to anyone with an iPhone, iPad or Mac. They text the code to the line once, and from then on your app can message them. Before you share it, set the app name people see with PATCH /v1/me and a name of 1 to 40 characters. This script prints the link from POST /v1/invites and lists who has joined with GET /v1/contacts:
import os
import requests
API = "https://blueping.pages.dev/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['BLUEPING_API_KEY']}"}
invite = requests.post(f"{API}/invites", headers=HEADERS, timeout=15).json()
print(f"Share {invite['url']} or have them text {invite['code']} to {invite['line']}")
contacts = requests.get(f"{API}/contacts", headers=HEADERS, timeout=15).json()
for contact in contacts["data"]:
print(contact["id"], contact["handle"], contact["remaining_before_reply"])Each contact has an id that starts with ct_, a handle (their phone number or email address), and remaining_before_reply, the number of messages you can still send before they answer.
Send an iMessage to someone who joined
Once a person has joined, to takes their contact id, their phone number in E.164 format or their email address. The rest of the request is the same as when you texted yourself:
import os
import requests
response = requests.post(
"https://blueping.pages.dev/v1/messages",
headers={"Authorization": f"Bearer {os.environ['BLUEPING_API_KEY']}"},
json={"to": "+15551234567", "text": "Thanks for joining. Reply here any time."},
timeout=15,
)
print(response.status_code, response.json())Handle the errors you are most likely to see
Errors come back as JSON with an error object that holds a code and a message. Three of them are part of normal life on the free line, so it pays to wrap sends in a helper that deals with them:
import os
import requests
API = "https://blueping.pages.dev/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['BLUEPING_API_KEY']}"}
def send(to, text):
response = requests.post(
f"{API}/messages", headers=HEADERS, json={"to": to, "text": text}, timeout=15
)
if response.ok:
return response.json()
error = response.json()["error"]
if error["code"] == "contact_not_joined":
print(f"{to} has not joined yet. Send them {error['join_url']}")
elif error["code"] == "awaiting_reply":
print(f"Wait for {to} to reply before sending more.")
elif error["code"] == "daily_limit_reached":
print(f"Daily limit reached. Sending resumes at {error['resets_at']}.")
else:
raise RuntimeError(f"{error['code']}: {error['message']}")
return None
send("+15551234567", "Your build finished.")contact_not_joined(409): the person has not texted your code yet, or has since left your app or switched to another one. The error includesjoin_url, ready to send them.awaiting_reply(429): you sent 5 messages in a row and the person has not answered yet. The error includesremaining: 0, and their next reply resets the count.remaining_before_replyinGET /v1/contactslets you check before you get there.daily_limit_reached(429): your account sent its 100 messages for the day. The count resets at 00:00 UTC, and the error'sresets_atgives the exact time.
The helper raises on everything else. contact_opted_out means the person unsubscribed, line_busy means the shared line reached its daily volume, line_error (502) is worth retrying later, unauthorized (401) means the API key was not accepted, and the 400 errors (invalid_json, text_required, text_too_long, to_required) point to a problem in the request itself.
Read replies with GET /v1/messages
The simplest way to read replies is to poll. Without after, GET /v1/messages returns your latest messages, oldest first. Each response includes next_after; pass it back as after and the next call returns only newer messages. Replies are the ones whose direction is in.
import os
import time
import requests
API = "https://blueping.pages.dev/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['BLUEPING_API_KEY']}"}
after = None
while True:
params = {"limit": 50}
if after:
params["after"] = after
page = requests.get(f"{API}/messages", headers=HEADERS, params=params, timeout=15).json()
for message in page["data"]:
if message["direction"] == "in":
print(f"{message['contact']['handle']}: {message['text']}")
after = page["next_after"] or after
time.sleep(10)Polling is fine for scripts and quick experiments. For anything that runs around the clock, a webhook avoids the constant requests: Blueping calls you when something happens.
Receive replies with a Flask webhook
Register a public https:// URL with PUT /v1/webhook. The response includes a signing secret that starts with whsec_; save it as BLUEPING_WEBHOOK_SECRET. DELETE /v1/webhook removes the webhook when you no longer need it.
import os
import requests
response = requests.put(
"https://blueping.pages.dev/v1/webhook",
headers={"Authorization": f"Bearer {os.environ['BLUEPING_API_KEY']}"},
json={"url": "https://your-app.example.com/blueping"},
timeout=15,
)
print(response.json()["secret"])Blueping then sends each event as a JSON POST with an id, a type, a created_at and a data object. Replies arrive as message.received, and the other types are contact.joined, contact.left and contact.opted_out. Every request carries a Blueping-Signature header shaped like t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of the timestamp, a period and the raw body, keyed with your secret. This Flask app, saved as app.py, checks it before it trusts anything in the payload:
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["BLUEPING_WEBHOOK_SECRET"]
app = Flask(__name__)
def valid_signature(raw_body: bytes, header: str) -> bool:
try:
fields = dict(part.split("=", 1) for part in header.split(","))
timestamp, signature = fields["t"], fields["v1"]
if abs(time.time() - int(timestamp)) > 300:
return False
except (KeyError, ValueError):
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), signature.encode())
@app.post("/blueping")
def blueping_webhook():
raw_body = request.get_data()
if not valid_signature(raw_body, request.headers.get("Blueping-Signature", "")):
abort(400)
event = json.loads(raw_body)
if event["type"] == "message.received":
message = event["data"]["message"]
print(f"{message['from']['handle']}: {message['text']}")
return "", 204Compute the HMAC over the raw bytes from request.get_data(), because JSON that was parsed and serialized again won't reliably match the bytes that were signed. hmac.compare_digest compares in constant time, so the check gives nothing away through timing, and the five-minute window on t rejects an old request that someone captured and sends again.
Your endpoint has 10 seconds to answer with a 2xx. A failed delivery is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. Start the app with flask run --port 5000, make it reachable at the URL you registered, then reply from your iPhone to the message you sent yourself earlier: it shows up in the Flask log. A contact.joined event is also a good moment to send a welcome message.
Build an echo bot
An echo bot is the same webhook plus one API call: every message.received event gets its text sent back to message.from.id. Save it as echo_bot.py:
import hashlib
import hmac
import json
import os
import time
import requests
from flask import Flask, abort, request
API = "https://blueping.pages.dev/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['BLUEPING_API_KEY']}"}
SECRET = os.environ["BLUEPING_WEBHOOK_SECRET"]
app = Flask(__name__)
def valid_signature(raw_body: bytes, header: str) -> bool:
try:
fields = dict(part.split("=", 1) for part in header.split(","))
timestamp, signature = fields["t"], fields["v1"]
if abs(time.time() - int(timestamp)) > 300:
return False
except (KeyError, ValueError):
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), signature.encode())
@app.post("/blueping")
def blueping_webhook():
raw_body = request.get_data()
if not valid_signature(raw_body, request.headers.get("Blueping-Signature", "")):
abort(400)
event = json.loads(raw_body)
if event["type"] == "message.received":
message = event["data"]["message"]
reply = requests.post(
f"{API}/messages",
headers={**HEADERS, "Idempotency-Key": f"echo-{message['id']}"},
json={"to": message["from"]["id"], "text": f"You said: {message['text']}"},
timeout=8,
)
if reply.status_code >= 500:
return "", 503
return "", 204export BLUEPING_WEBHOOK_SECRET="whsec_..."
flask --app echo_bot run --port 5000The 8-second timeout keeps the handler inside the 10-second window. If the handler fails anyway after the echo went out, the redelivered event carries the same message id, so the Idempotency-Key makes the API return the first answer instead of sending the echo twice. A server error from the API, such as line_error, returns 503, which makes Blueping deliver the event again later.
The bot never runs into awaiting_reply, because each message it sends answers one the person just sent. Every echo does count toward the 100 messages a day.
What the free plan covers
- 100 messages a day per account, reset at 00:00 UTC.
- Up to 5 messages in a row to the same person before they reply.
- Text messages of up to 2,000 characters. Attachments are not part of API v1 yet.
- iMessage only, so the people you text need an iPhone, iPad or Mac. There is no SMS or Android delivery.
- No typing indicators, read receipts or tapbacks.
The free plan costs $0. A dedicated number is coming soon, with a waitlist.
Questions
Can I send an iMessage from Python on Windows or Linux?
Yes. Your script only makes HTTPS requests to the Blueping API, so it runs on any system with Python. You need an iPhone to sign up, and the people you message need an iPhone, iPad or Mac.
Does Apple have an official iMessage API?
No. Apple has no public iMessage API. Blueping is an independent service, not affiliated with Apple, and its API is the one this guide uses.
Can my app text any phone number?
No. Your app can message a person only after they text your join code to the line. That text is their opt-in, and they can leave with LEAVE or unsubscribe from every app with STOP at any time.
Can I send iMessages to Android phones or fall back to SMS?
No. Blueping sends iMessage only, so recipients need an iPhone, iPad or Mac, and there is no SMS fallback.
How do I receive iMessages in Python?
Poll GET /v1/messages and keep the messages whose direction is "in", or register a webhook with PUT /v1/webhook and handle message.received events in Flask after checking the Blueping-Signature header.
Is the Blueping iMessage API free?
Yes. The free plan costs $0 and covers 100 messages a day per account on the shared line, with the count reset at 00:00 UTC. A dedicated number is coming soon, with a waitlist.