How do I get my phone's texts as a webhook?
Pair the phone with Sparetext, then add your URL on the Webhooks page with the Text received event. Each text the phone gets is posted to your URL as JSON signed with HMAC-SHA256, and a delivery that fails is tried again, 10 tries over about 3 days. An Android phone does this on its own; an iPhone does it through Sparetext for Mac.
Set it up
You need a Sparetext account (Free is 1 phone and 300 texts a month, and texts you receive don't count) and an https URL on the public internet that accepts a POST.
- Pair the phone: Phones › Add phone, then Android, or iPhone + Mac relay, and follow the steps.
- Choose whose texts it saves: Everyone, or Only people you've texted. A text it doesn't save never reaches your webhook; change it later in the phone's ⋯ menu on the Phones page.
- Open Webhooks › Add webhook, paste your endpoint URL and keep Text received ticked. With more than one phone, tick the phones it should hear, or none for all of them.
- On the new webhook, open Details and signing secret and copy the secret to your server.
- Click Send test. Your server gets a signed ping event, and the result shows on the webhook.
- Text the phone from another phone. A message.received event arrives, and Recent deliveries lists it.
What your server receives
A POST with these headers and a JSON body, sent as compact JSON on one line and laid out here to read. The contact field is the sender's number in E.164, or a name such as BANK for an alphanumeric sender, and a long text that arrived in parts comes as one. The contactName field appears when you've saved a name for the number, and sim is the SIM it came in on.
POST /hooks/sparetext HTTP/1.1
Content-Type: application/json
User-Agent: Sparetext-Webhooks/1.0
X-Sparetext-Event: message.received
X-Sparetext-Delivery: evt_q8m2k7x4p9za
X-Sparetext-Attempt: 1
X-Sparetext-Signature: t=1793438045,v1=9c2f…
{
"id": "evt_q8m2k7x4p9za",
"type": "message.received",
"createdAt": "2026-10-31T09:14:05.212Z",
"data": {
"message": {
"id": "msg_20261031_x7k2p9qa",
"direction": "inbound",
"contact": "+12025550123",
"text": "Yes, 7pm works. See you then!",
"sim": 1,
"status": "received",
"deviceId": "dev_a3b2c4d5e6f7",
"deviceName": "Shop phone",
"source": "device",
"read": false,
"createdAt": "2026-10-31T09:14:05.187Z",
"receivedAt": "2026-10-31T09:14:03.000Z"
},
"contactName": "Sam Lee"
}
}Check the signature in Node.js
X-Sparetext-Signature is t=<unix seconds>,v1=<hex>: an HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the webhook's secret. Check it over the bytes as they arrived, before you parse them, and refuse a timestamp more than 5 minutes off, so an old request can't be replayed. Each try is signed again as it goes out, so a late retry still passes.
Here is a whole receiver on Node's own http module: it answers at once, then skips an event id it has already handled.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
// rawBody: the request body exactly as it arrived (a string or Buffer).
function fromSparetext(rawBody, header, secret) {
header = String(header ?? "");
const t = /(?:^|,)t=(\d+)/.exec(header)?.[1];
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const want = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"));
// For a day after you rotate the secret there are two v1= signatures: either may be yours.
return [...header.matchAll(/v1=([0-9a-f]{64})/g)].some(([, v1]) => timingSafeEqual(want, Buffer.from(v1)));
}
const secret = process.env.SPARETEXT_WEBHOOK_SECRET;
const handled = new Set(); // keep event ids in your database instead
createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks);
if (!fromSparetext(raw, req.headers["x-sparetext-signature"], secret)) {
res.writeHead(401).end();
return;
}
res.writeHead(200).end(); // answer within 5 seconds, then do the work
const event = JSON.parse(raw);
if (handled.has(event.id)) return; // a retry of an event you already have
handled.add(event.id);
if (event.type === "message.received") {
const { contact, text } = event.data.message;
console.log(`Text from ${contact}: ${text}`);
}
}).listen(3000);Check the signature in Python
The same check with nothing but hmac, hashlib and time from the standard library. Pass the raw body as bytes; the last lines show where Flask, Django and FastAPI keep it.
import hashlib
import hmac
import time
def from_sparetext(raw_body, header, secret):
"""raw_body: the request body as bytes. header: the X-Sparetext-Signature value."""
pairs = [p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p]
t = next((v for k, v in pairs if k == "t"), "")
if not (t.isascii() and t.isdigit()) or abs(time.time() - int(t)) > 300:
return False # no timestamp, or more than 5 minutes off: refuse it
signed = t.encode() + b"." + raw_body
want = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode()
# For a day after you rotate the secret there are two v1= signatures: either may be yours.
return any(hmac.compare_digest(want, v.encode()) for k, v in pairs if k == "v1")
# The raw body: request.get_data() in Flask, request.body in Django,
# await request.body() in FastAPI. The header: request.headers.get("X-Sparetext-Signature")Rotating the secret
Choose New signing secret… in the webhook's ⋯ menu. For 24 hours each request carries two v1= signatures, one for each secret, so the checks above accept either while you move your server to the new one. After that only the new secret signs.
When your server doesn't answer
Answer with any 2xx within 5 seconds, then do the work. No answer in time, a redirect or any other status counts as failed, and the event is tried again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 10 hours, a day and a day: 10 tries over about 3 days.
Every try sends the same body with the same id, which is also in X-Sparetext-Delivery, and X-Sparetext-Attempt counts the tries, so skip an id you've already handled. Events can arrive out of order.
If every delivery has failed for a whole day, Sparetext pauses the webhook, emails you, and drops the tries still waiting. Fix the receiver, then turn the webhook back on. Its log keeps the last 50 tries, with what was sent and what came back, and Send again resends a logged event with its id. To catch up on texts that came in meanwhile, list them with GET /api/v1/messages?direction=inbound&since=<time>.
Reply with your server's answer
A webhook's answer counts only for its status. To text back what your server says, use an automation: Automations › New automation, A text arrives, then Call a URL with Text back what it answers turned on. Sparetext posts the text to your URL, signed with the automation's own secret (shown when you open the saved automation again), and waits up to 8 seconds. A 2xx answer of {"reply": "…"} as JSON, or a text/plain body, goes back as a text from the same phone and SIM, up to 1,600 characters.
An automation's call is tried once, and a rule answers the same number at most once a minute, so two auto-replying phones can't text each other forever. For events you can't miss, keep the webhook too.
POST /sms-bot HTTP/1.1
Host: example.test
Content-Type: application/json
User-Agent: Sparetext-Automations/1.0
X-Sparetext-Signature: t=1793438045,v1=4be1…
{
"automation": { "id": "auto_k2m8x3q9p4za", "name": "Order bot" },
"message": { "contact": "+12025550123", "text": "Where is order 1042?", … }
}
HTTP/1.1 200 OK
Content-Type: application/json
{"reply": "Order 1042 ships today."}The other events
Tick more events on the same webhook, or add another. For texts you send: message.sent when the phone hands one to the carrier, message.delivered when the carrier confirms it (Android only; from an iPhone or Mac, sent is final), message.failed with the reason in error, and message.canceled. The message.sent_from_phone event is a text typed in the phone's own messaging app, when the phone shares those.
The call.missed event comes when an Android phone misses a call, with Missed calls on in its settings. For the phones themselves, device.offline and device.online say an Android phone or Mac relay stopped checking in and came back, device.battery_low comes at 15% or less while not charging, and device.paired when a phone joins. Every event about a text carries the ref you sent it with.
No public server
Webhook URLs must be public https addresses: Sparetext won't post to localhost or a private network. Without one, poll instead. GET /api/v1/messages?order=asc lists texts oldest first from where the last call stopped: keep the after from each answer and send it back next time. A text is listed once it's 3 seconds old, so a poll never skips or repeats one.
Without code, an automation can post each text to Slack, Discord, Microsoft Teams, Telegram or ntfy (presets under Call a URL), forward it to another number, or email it to you.
# Replies only, oldest first, from where the last run stopped (empty the first time)
curl "https://sparetext.dev/api/v1/messages?order=asc&direction=inbound&after=$AFTER" \
-H "x-api-key: $SPARETEXT_API_KEY"
# → {"messages": [...], "after": "MjAyNi0xMC0zMVQw…", "hasMore": false}Pictures (MMS)
An Android phone (app 1.4.0 or later) and the Mac relay (1.2.0 or later) pass on pictures too; the Mac relay turns HEIC photos into JPEG. The message then has media, a list of {id, contentType, size, expiresAt}, and text may be empty. Download each picture with GET /api/v1/media/{id} and your API key. Pictures are kept 30 days, then the download answers 410 media_expired.
From an iPhone
iOS lets no app read texts, so an iPhone alone can't pass them all on like an Android phone. Sparetext for Mac can: with Text Message Forwarding on, the iPhone's texts reach the Mac, and the relay reads each one from Messages, with its number. It pairs set to save only texts from people you've texted; choose Everyone in its ⋯ menu if customers text you first. The Mac has to be awake and signed in to Messages.
Without a Mac, a Shortcuts automation (Message as the trigger, Run Immediately, and the Forward Text to Sparetext action) hands texts to the Sparetext app. iOS may ask you to pick a sender or a word, and only matching texts come through, sometimes with a contact's name in place of the number. It suits a few known senders; for every text, use the Mac relay.
Limits
Replies sent over RCS never reach Sparetext: when the phone's messaging app has RCS chats on, someone who also has RCS may reply as a chat, and chats stay in that app. Turn RCS chats off on a phone that receives replies (Google Messages: your profile picture › Messages settings › RCS chats); the Android app warns you while they're on.
A phone with no connection keeps the texts it gets and passes them on when it's back. An account can have up to 20 webhooks. With private mode on, text arrives as an e2e1. envelope that only your own key opens.
Questions people ask
- Can I get received SMS as a webhook from an Android phone?
- Yes. Install Sparetext on the phone, pair it, and add a webhook with the Text received event. The app passes each text on as it arrives, and Sparetext posts it to your URL, signed with HMAC-SHA256.
- How do I know a webhook came from Sparetext?
- Check X-Sparetext-Signature: an HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the webhook's secret, and refuse a timestamp more than 5 minutes old. The Node.js and Python checks on this page do both.
- What happens when my server is down?
- Each event is tried 10 times over about 3 days, with the same body and id. If every delivery fails for a whole day, the webhook is paused and you get an email.
- Can my webhook reply to the text?
- Use an automation: Call a URL with Text back what it answers turned on. A 2xx answer of {"reply": "…"} within 8 seconds is texted back from the same phone.
- Do I need a public server?
- No. A script can poll GET /api/v1/messages?order=asc, and automations can post texts to Slack, Discord, Microsoft Teams, Telegram or ntfy, forward them, or email them to you.
- Does it work with n8n, Zapier or Make?
- Yes. Use their webhook trigger's URL as the webhook's address. In n8n, a Code node checks the signature; the n8n SMS page shows how.
- Can I get an iPhone's texts by webhook?
- Yes, through Sparetext for Mac, which reads the texts the iPhone forwards to Messages. A Shortcuts automation on the iPhone alone passes on only texts that match a sender or a word you choose.
- Do received texts count toward my plan?
- No. Plans count the texts you send, not the ones you receive.
Try it on the phone you have
Free for 1 phone and 300 texts a month. An Android pairs in two minutes.