iMessage customer support, routed to Slack or your help desk
ZeroSettle gives a support team an iMessage number that customers text from a “Message us” button. ZeroSettle posts each inbound message, signed, to your webhook about 1–2 seconds after the line receives it, so a relay you run can post it to a Slack thread per customer, or to Intercom, Zendesk or Front through their APIs, and send the team’s answer back with POST /api/imessage/send. ZeroSettle’s docs include an example Slack relay with a slash command for replies. These are integrations you run, not built-in connectors; with no code, the ZeroSettle console is your team’s inbox.
Key takeaways
- What it is: a support number on iMessage. Customers text it from a “Message us” button, and ZeroSettle’s signed webhook hands each text to a relay you run about 1–2 seconds later.
- Where answers happen: a Slack thread per customer, a help desk such as Front, Intercom or Zendesk through its API, or the ZeroSettle console with no code.
- Price: Pro is $49/month for one number; Growth is from $599/mo. There are no per-message fees, and a relay you run uses no AI credits.
- Main limit: none when the customer texts first. Texting a customer who never texted the line counts as a new contact (Pro: 5 a day per number), and a line sends one message at a time, so a burst of replies queues.
- What it does not do: there is no native Slack or help-desk app, no SMS fallback for Android, no group chats and no read receipts in the API.
Who uses iMessage for customer support?
iMessage customer support through ZeroSettle fits support teams at apps, SaaS products and DTC brands whose customers mostly use iPhone and whose agents already work in Slack, Intercom, Zendesk or Front. ZeroSettle is an iMessage API: a real phone number on real iMessage, running on Apple hardware we operate for you in a server facility, that your code texts customers from and hears back on through a bearer token and signed webhooks.
Customers need no app and no login: they text, you answer, and the thread stays in Messages under your name and photo, while your team stays in the tool it already watches. If most customers use Android, see when ZeroSettle is not a fit; for order lookups from store data, see order-status support for Shopify brands.
How iMessage for customer support works, step by step
A customer texts your ZeroSettle number, a signed webhook hands the text to a relay you run, and the team answers from Slack or a help desk.
- The customer starts the thread from a “Message us” button: an
sms:+18055550100&body=…link prefilled with an order number or support code such as “K7Q2”, never an email address or a secret (see how to get customers to text first). - ZeroSettle posts a signed
message.receivedto your webhook about 1–2 seconds after the line receives it. A screenshot arrives in the same event as animageobject. - The relay verifies and forwards it. It checks
X-ZeroSettle-Signature, dedupes onmessage.idplusevent, and posts into the Slack thread keyed by the customer’s handle. - A 👍 tapback from
POST /api/imessage/reactions("reaction": "like") tells the customer the message landed. - The typing bubble from
POST /api/imessage/typing(ttl_sup to 60) shows while an agent looks it up, and clears when the reply goes. - The agent replies from Slack with a slash command that calls
POST /api/imessage/send(normally 2–5 seconds). Every send, console replies included, echoes back asmessage.sentinto the same thread.
Help desks work the same way; ZeroSettle’s docs note that “Intercom, Zendesk, Front, and Linear all have equivalent inbound APIs.” In Front, an application channel you build syncs each inbound text in, and Front sends agents’ replies to its webhook, where your code calls POST /api/imessage/send. For CRM logging, see iMessages in HubSpot or Salesforce; for a low-code relay, see Zapier, n8n or Make.
With no code at all, the ZeroSettle console is the inbox: it shows every conversation live, and you can invite teammates to answer from it.
Example conversation: customer support
A billing question answered from Slack, with the relay’s 👍 and the agent’s typing bubble.
- Wed 9:41 AM
- Customer: Hi! I need help with my account (ref K7Q2). I was charged twice this month👍
- Planwise is typing
- Planwise: You’re right, sorry about that. The duplicate $12 charge is refunded and should show in 3–5 days.
- Customer: amazing thank you
/imsg with the handle alone for the typing bubble, then with the reply.Setup: the docs’ Slack relay, with a thread per customer
This relay adapts the one in the customer support section of ZeroSettle’s docs: it keeps one Slack thread per customer (chat.postMessage with thread_ts), checks Slack’s request signature, retries line_busy, and answers Slack at once, reporting the result to response_url. Slack shows an operation_timeout error unless a slash command is answered within 3,000 milliseconds, and a ZeroSettle send normally takes 2–5 seconds.
# support_relay.py, adapted from ZeroSettle's docs. Run: uvicorn support_relay:app --port 8100
import asyncio, hmac, hashlib, json, os, time
from urllib.parse import parse_qs
import httpx
from fastapi import BackgroundTasks, FastAPI, Request, Response
app = FastAPI()
ZS = "https://portal.zerosettle.io"
TOKEN, SECRET = os.environ["ZEROSETTLE_TOKEN"], os.environ["ZEROSETTLE_WEBHOOK_SECRET"]
SLACK_TOKEN, SLACK_SECRET = os.environ["SLACK_BOT_TOKEN"], os.environ["SLACK_SIGNING_SECRET"]
CHANNEL = os.environ["SLACK_CHANNEL_ID"]
ZS_AUTH = {"Authorization": f"Bearer {TOKEN}"}
seen: set[tuple[int, str]] = set() # use your database in production
threads: dict[str, str] = {} # handle -> Slack thread ts (database too)
def verify(header: str, raw: bytes, tolerance: int = 300) -> bool: # as in the docs
try:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = int(parts["t"]), parts["v1"]
except Exception:
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(SECRET.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
async def post_thread(handle: str, text: str) -> None:
text = text.replace("&", "&").replace("<", "<").replace(">", ">") # no @channel from a text
body = {"channel": CHANNEL, "text": text}
if handle in threads:
body["thread_ts"] = threads[handle]
async with httpx.AsyncClient() as c:
r = await c.post("https://slack.com/api/chat.postMessage", json=body,
headers={"Authorization": f"Bearer {SLACK_TOKEN}"})
if r.json().get("ts"):
threads.setdefault(handle, r.json()["ts"])
async def relay(event: str, m: dict) -> None:
h = m["handle"]
if event == "message.received":
photo = " [photo]" if m.get("image") else ""
await post_thread(h, f"*{h}*: {m.get('text') or ''}{photo}\n_reply with_ `/imsg {h} your answer`")
async with httpx.AsyncClient(timeout=90) as c: # 👍 = "seen, on it"; queues like a send
await c.post(f"{ZS}/api/imessage/reactions", headers=ZS_AUTH,
json={"message_id": m["id"], "reaction": "like"})
elif event in ("message.sent", "message.failed", "reaction.received"): # console sends too
await post_thread(h, f"_{event}:_ {m.get('text') or ''}")
@app.post("/webhook")
async def webhook(req: Request, tasks: BackgroundTasks):
raw = await req.body()
if not verify(req.headers.get("X-ZeroSettle-Signature", ""), raw):
return Response(status_code=401)
ev = json.loads(raw); m = ev.get("message")
if m is None or (m["id"], ev["event"]) in seen: # typing.* has no message; dedupe retries
return {"ok": True}
seen.add((m["id"], ev["event"]))
tasks.add_task(relay, ev["event"], m) # 2xx now, within the 10 s timeout
return {"ok": True}
def slack_ok(req: Request, raw: bytes) -> bool: # Slack's v0 signature
try:
ts = int(req.headers["X-Slack-Request-Timestamp"])
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > 300:
return False
exp = "v0=" + hmac.new(SLACK_SECRET.encode(), f"v0:{ts}:".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(exp, req.headers.get("X-Slack-Signature", ""))
# /imsg +18055559876 We just refunded that charge. (handle alone: typing bubble for 60 s)
@app.post("/slack/imsg")
async def slack_imsg(req: Request, tasks: BackgroundTasks):
raw = await req.body()
if not slack_ok(req, raw):
return Response(status_code=401)
form = parse_qs(raw.decode())
handle, _, reply = form.get("text", [""])[0].strip().partition(" ")
tasks.add_task(answer, handle, reply.strip(), form["response_url"][0])
return {"text": f"Working on {handle}..."} # inside Slack's 3 s window
async def answer(handle: str, reply: str, response_url: str) -> None:
path, body = (("send", {"to": handle, "text": reply}) if reply
else ("typing", {"to": handle, "state": "start", "ttl_s": 60}))
async with httpx.AsyncClient(timeout=90) as c:
for _ in range(3):
r = await c.post(f"{ZS}/api/imessage/{path}", headers=ZS_AUTH, json=body)
if r.status_code != 429 or r.json()["error"]["code"] != "line_busy":
break
await asyncio.sleep(2) # line_busy: retry after 2 s
note = "Done." if r.status_code == 200 else "Failed: {code}: {message}".format(**r.json()["error"])
await c.post(response_url, json={"text": note})
Give the Slack app the chat:write bot scope, invite it to the channel, and point an /imsg command at /slack/imsg. Register /webhook with PUT /api/imessage/webhook and store the signing secret, which is shown once; the iMessage webhook guide covers retries and a signed test event. Two things are left to you:
- Photos. A received photo’s
image.urlneeds your ZeroSettle token, so fetchGET /api/imessage/messages/{id}/imageand upload the file to the thread. Only the first of several photos is delivered. - Identity. Store the handle on the customer’s account the first time they text. A customer on an iPad or Mac may appear as an iMessage email address, so treat the handle as an opaque string.
Limits and consent for customer support
A support line is customer-initiated, so ZeroSettle’s new-contact limits rarely apply: anyone who texted the line first is never limited. A support team meets the one-send-at-a-time queue instead, and a cap on unanswered messages.
| Situation | Pro | Growth |
|---|---|---|
| New-contact limit when the customer texted first | None | None |
| First text to someone the line has never messaged | 5 a day | 50 a day, 3 per 15 minutes |
| Messages since the customer last sent anything | 2 (sends within 5 minutes of the first count as one), then 403 awaiting_reply | |
| Several sends at once | One at a time per line; 429 line_busy after about 12 s of waiting | |
Source: ZeroSettle iMessage API docs, “Errors, limits, and compliance”. The day runs 3 AM to 3 AM Eastern.
The limit math for a busy shift
- Five agents reply at once. At 2–5 seconds per send, the fifth reply waits 8 to 20 seconds behind the other four; past about 12 seconds it gets
429 line_busy, which the relay retries after 2 seconds. - Following up 30 email tickets by text on one Pro number. Those customers never texted the line, so each is a new contact: 5 a day, so the follow-ups take six days, each new batch waiting for the 3 AM Eastern reset. For that volume, book a demo to upgrade to Growth. Text only customers who agreed to texts about their ticket, and leave links out of that first text, where they can’t be tapped.
- “Did that fix it?” on either plan. Your answer is the first unanswered message, so a follow-up sent more than 5 minutes later is the second, and a third returns
403 awaiting_reply. Anything the customer sends back, a tapback included, resets the count.
Keep the thread transactional
Your first reply should say who is answering and when to expect the next one; if nobody is on shift, say so. ZeroSettle’s docs add: “Do not send automated marketing into a support thread. It is a different consent, and it is how support lines get reported as spam.” This isn’t legal advice.
STOP turns up in support texts
ZeroSettle treats any text with the word STOP in it, or a clear request like “take me off your list”, as an opt-out across every number on your account, so “it won’t stop charging me” counts. Replies and typing to that customer then return 403 recipient_opted_out, which the relay reports in Slack, so answer by email; the customer opts back in by texting START on its own. See how STOP and opt-outs work.
When ZeroSettle is not a fit for customer support
ZeroSettle is the wrong tool for a support line in these cases:
- You want a native Zendesk, Intercom or Front app with no code. There isn’t one.
- Most customers use Android. There is no SMS fallback, and the
sms:link opens the SMS app on Android, so keep email or chat for them. - You need group conversations, such as a customer and their accountant in one thread.
- Customers send screen recordings or log files. Inbound video and files arrive without the file.
- You need Apple Pay, list pickers or time pickers in the chat. Those are Apple Messages for Business features; see Apple Messages for Business vs an iMessage number.
- You report on read receipts. The API reports
message.sent(left the line) andmessage.failed, not delivered or read.
How much does iMessage for customer support cost?
A support line costs the plan price: ZeroSettle has no per-message fees, and messages in both directions are unlimited (and counted).
| Plan | Price | Fits |
|---|---|---|
| Pro | $49/month | One number to start, 5 new contacts a day per number, unlimited messages with people who message you first, self-serve, no setup fee, 30-day money-back guarantee |
| Growth | From $599/mo | Several numbers working as one, with one API key and webhook; dedicated support; set up after a demo |
Get a number, compare ZeroSettle’s plans, or browse the use cases hub.
Frequently asked questions
Can the hosted AI agent answer first and hand off to Slack?
Not on the same number. On a line group set to Managed by AI Agent (Beta), inbound texts skip your webhook and your sends are refused with 403 managed_by_zerosettle, so each number is answered by the agent or by your team.
Can I tell whether a customer was happy with the answer?
Only from what they send back. Their tapbacks arrive as reaction.received, which the relay above posts into the thread; ZeroSettle’s docs call a ❤️ on “Fixed, sorry about that” your CSAT survey. The API does not report whether a reply was read.
How is this different from Apple Messages for Business?
A ZeroSettle line is a phone number customers text from Messages and can save, with no Apple review before you start. Apple Messages for Business needs an Apple-approved Messaging Service Provider and an Experience Review before launch, and adds Quick Reply, List Picker, Time Picker and Apple Pay in the conversation.
Sources
- ZeroSettle iMessage API docs: “Customer support”, “Send a message”, “Attachments”, “Typing indicators”, “AI agent”, “Webhooks, step by step” and “Errors, limits, and compliance”. Checked October 12, 2026.
- ZeroSettle plans, zerosettle.io. Checked October 12, 2026.
- Implementing slash commands, Slack Developer Docs (3,000 ms acknowledgment,
response_url). Checked October 8, 2026. - Verifying requests from Slack, Slack Developer Docs. Checked October 8, 2026.
- chat.postMessage method, Slack Developer Docs (
thread_ts,chat:write). Checked October 8, 2026. - Getting Started with Application Channels, Front Developer docs. Checked October 8, 2026.
- Apple Messages for Business, Apple Business Register. Checked October 8, 2026.
Changelog
- First published.