Use iMessage polls and tapbacks with Grok Bot
Once your Grok Bot has a ZeroSettle iMessage number, it can send a real Messages poll with the same API call it uses for texts. Every vote, changed vote and removed vote comes back as a signed poll.voted webhook with the current tally, so the Bot can book the slot people picked, flag an unhappy customer, or post a headcount. Tapbacks come back the same way, which gives you a one-tap approval: the Bot texts you a draft, you tap π, and only then does it go out.
Key takeaways
- Polls are one call:
POST /api/imessage/sendwith apollof 2 to 12 options, to someone who has texted the number or into a group withchat_id. - Every vote is an event:
poll.votedfires on each vote, change or removal and carries the results as they stand. Act on those results, not a running count. - Tapbacks are events too:
reaction.receivednames the message that was tapped, so your π on a draft can mean "send it" and π "drop it". - Two small additions: a few lines in the relay from the setup guide to forward votes and your tapbacks, and a skill update that tells the Bot what each one means.
- Not confirmed: Grok Bot's docs don't say whether a tapback relayed by webhook counts as your approval for Auto Review. Test on your own number first.
What can a Grok Bot do with polls and tapbacks?
Grok Bot, the always-on agent from the Grok team, has no texting tool of its own. The setup guide gives it one: a ZeroSettle number, an API key on the Bot's cloud computer, a webhook routine, and a small relay between the two. ZeroSettle is backed by Y Combinator and built by former Apple engineers. It runs your number on real iMessage, on Apple hardware we operate in a server facility, so people see blue bubbles and the name and photo you set.
With that in place, two iMessage features become useful in ways a plain text isn't:
- Polls. Recipients on iOS 26 or later vote right in the conversation. One tap is easier than typing "Thursday works, but only after 5", and the Bot gets back a structured answer: which option, who picked it, and the full tally.
- Tapbacks. The Bot can tapback a customer's message to say "seen" without another text. More useful for you as the owner: your tapback on one of the Bot's messages reaches the webhook with the id of that message, which makes it a clean yes or no.
The three examples below use a fictional studio, Tidewater Pilates, and fictional people and numbers. They're patterns, not a product: the Bot does what its skill and routine say.
Which webhook events carry votes and tapbacks?
ZeroSettle POSTs every message event on the number to your webhook, signed with HMAC-SHA256. The setup guide's relay forwards only message.received. These are the other events this post uses:
| What happened | Event | What the message carries |
|---|---|---|
| Someone voted, changed a vote or removed it | poll.voted | kind: "poll_vote"; target_id is the poll; vote.option_ids is that person's whole selection now ([] means they removed it); text like Voted βTueβ; poll is the results after this vote |
| Someone tapbacked one of the number's messages | reaction.received | kind: "reaction"; reaction (love, like, dislike, laugh, emphasize, question); target_id is the message they tapped |
| Someone sent their own poll | message.received | kind: "poll"; votes on it arrive as poll.voted |
| The Bot's own poll or tapback went out | message.sent, reaction.sent | Ignore these in the relay, or the Bot answers itself |
In a group chat, all of these carry the group's chat_id, and handle is the member who voted or reacted. Two details shape the design. First, votes can arrive out of order: an older vote that turns up late changes nothing, and the event's vote.option_ids and text still give the selection that stands. So the Bot should read poll, the results, rather than add votes up itself. Second, the results are also on demand: GET /api/imessage/messages/{id}/poll returns one poll with its options, votes, voters (handles, oldest vote first) and voter_count. A person can choose more than one option.
How do votes and tapbacks reach the Bot?
Through the same relay. Add a toBot() helper above export default in the setup guide's relay.js, and the block below right after the line that parses the body (const { event, message: m } = β¦), before the message.received check:
// Above export default: forward one event to the Grok Bot routine, once.
async function toBot(env, ctx, key, payload) {
if (env.SEEN && (await env.SEEN.get(key))) return new Response("duplicate");
try {
const res = await fetch(env.GROK_WEBHOOK_URL, {
method: "POST",
headers: { Authorization: `Bearer ${env.GROK_WEBHOOK_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ source: "zerosettle-imessage", ...payload }),
signal: AbortSignal.timeout(8000),
});
// Anything but 200 means no run started; a 502 makes ZeroSettle retry.
if (res.status !== 200) return new Response(`grok bot answered ${res.status}`, { status: 502 });
} catch (err) {
return new Response(`grok bot unreachable: ${err}`, { status: 502 });
}
if (env.SEEN) ctx.waitUntil(env.SEEN.put(key, "1", { expirationTtl: 86400 }));
return new Response("forwarded");
}
// In fetch(), right after the body is parsed:
if (event === "poll.voted" && m) {
// Group votes wake the Bot only if you ask; otherwise it checks at a deadline.
if (m.chat_id && env.GROUP_VOTES !== "each") return new Response("group vote: checked later");
return toBot(env, ctx, `${event}:${m.id}`, {
event, message_id: m.id, from: m.handle, chat_id: m.chat_id || null,
poll_id: m.target_id, selection: m.vote?.option_ids ?? [], text: m.text,
results: m.poll || null, // null if the poll isn't stored yet
});
}
if (event === "reaction.received" && m) {
// Only your own tapbacks, and only one-to-one: they are approvals.
const owner = (env.OWNER_HANDLE || "").toLowerCase();
if (!owner || m.chat_id || m.handle.toLowerCase() !== owner) return new Response("ignored");
return toBot(env, ctx, `${event}:${m.id}`, {
event, message_id: m.id, from: m.handle, reaction: m.reaction, target_id: m.target_id,
});
}
# wrangler.toml, under [vars]
OWNER_HANDLE = "+15555550100" # your own iPhone: the only tapbacks that count as approvals
GROUP_VOTES = "" # "each" wakes the Bot on every group vote
Why it's built this way:
- Only your tapbacks. The relay is the hard boundary. A customer's β€οΈ on a message never starts a run, so nobody but you can approve anything. Customers' tapbacks still land in
GET /api/imessage/messagesaskind: "reaction"if you want them in a weekly report. - Dedupe on event and id. ZeroSettle retries a failed delivery up to 3 more times with the same
message.idandevent. With the optional KV store, a retry doesn't start a second run. - Group votes off by default. Every forwarded event starts a Grok Bot run and spends usage, and a routine can run at most 200 times an hour. A 20-person RSVP doesn't need 20 runs.
How does the Bot send a poll?
With one more section in the "Text over iMessage" skill from the setup guide. Ask your Bot to add this to it:
Polls
Send (only to someone who has texted this number, or into a group):
POST /api/imessage/send {"to": "<handle>", "poll": {"question": "...", "options": ["...", "..."]}}
POST /api/imessage/send {"chat_id": "<chat_id>", "poll": {...}}
2 to 12 options, each up to 150 characters, all different; question up to 300.
No "text" with a poll. End the question with "(or just reply)": older iPhones
can't vote, but they get the question as a text.
Save the response "id" as the poll id: /workspace/.zerosettle/polls/<id>.json with
who it went to, why, what to do with the result, and any deadline.
Results: GET /api/imessage/messages/<poll id>/poll. Trust its options[].votes,
options[].voters and voter_count over your own count.
403 attachment_before_reply: they haven't texted us yet; ask in plain text.
409 poll_unsupported: send the options as a numbered text instead.
Drafts for my approval
When something needs my OK, text me (+15555550100) one message:
Draft to <name> (<handle or group name>):
"<exact text>"
π sends it, π drops it, β and I'll ask what to change.
Save {"to" or "chat_id", "text"} to /workspace/.zerosettle/drafts/<id of that text>.json.
Here is the call underneath, with the record ZeroSettle returns:
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"to":"+15555550142","poll":{"question":"Which works for a private session? (or just reply)","options":["Tue 7:00 am","Wed 12:30 pm","Thu 6:00 pm"]}}' \
$BASE/api/imessage/send
# {"ok": true, "status": "message_sent", "id": 46, "record": {"id": 46, "kind": "poll", "text": "π Which works for a private session? ...",
# "poll": {"question": "...", "options": [{"id": "186E4755-β¦", "text": "Tue 7:00 am", "votes": 0, "voters": []}, ...],
# "voter_count": 0}}}
iOS draws no question on the poll itself. As when someone makes a poll on their own iPhone, the question follows as a separate text with its own id. It counts toward your limits like any message, and if it can't be sent the poll still stands.
Scheduling: let the customer pick a time
Dana (fictional, +1 555-0142) texts Tidewater's number: "Any private sessions next week?" The routine wakes the Bot with her text (for a business number, her handle has to pass the relay's ALLOWED_HANDLES, or leave that list empty). If you've connected your calendar app to the Bot, it can find three open slots there; if not, keep the slots in a file the skill points to. It sends the poll above and writes polls/46.json: "Dana's private session. On her vote, draft a confirmation for the owner. If she picks more than one, ask which."
Dana taps "Thu 6:00 pm". Within a couple of seconds the relay forwards this to the Bot's routine:
{"source": "zerosettle-imessage", "event": "poll.voted", "message_id": 48,
"from": "+15555550142", "chat_id": null, "poll_id": 46,
"selection": ["4B2Eβ¦"], "text": "Voted βThu 6:00 pmβ",
"results": {"question": "Which works for a private session? (or just reply)",
"options": [{"id": "186E4755-β¦", "text": "Tue 7:00 am", "votes": 0, "voters": []},
{"id": "9A1Cβ¦", "text": "Wed 12:30 pm", "votes": 0, "voters": []},
{"id": "4B2Eβ¦", "text": "Thu 6:00 pm", "votes": 1, "voters": ["+15555550142"]}],
"voter_count": 1}}
The Bot opens polls/46.json, sees what to do, and texts you a draft: "Thursday 6 pm is yours, Dana. See you at the studio." You tap π and it goes to Dana. If she changes her vote to Wednesday before you approve, a second poll.voted arrives with the new selection, and the Bot should replace the draft rather than send both. That's a line worth putting in the poll file. For the full booking flow, see the sibling post on appointment setting with Grok Bot.
Feedback: a three-option check-in
After a class, the Bot can ask regulars who already text the number: "How was class today? (or just reply)" with "Loved it", "It was fine" and "Not for me". The poll file says what each answer triggers:
- "Not for me": text the owner at once with the person's name and the class, then ask the person one plain question: "Sorry to hear it. What would have made it better?"
- Anything else: the Bot records the vote and sends nothing more. The tallies go into your morning summary; see the owner daily brief post.
Keep the poll for listening, not for sorting people. Sending a review link only to the people who said "Loved it" is review gating; the review requests post explains why to avoid it and how to ask everyone the same way. For more survey formats, see customer feedback over iMessage.
RSVP: a headcount in a group chat
Tidewater runs a Saturday beach class with a group chat for regulars. You ask the Bot: "Poll the beach group: coming Saturday at 9? Yes, No, Maybe. Post the headcount Friday at 6 pm and tell me who's in." It sends the poll with the group's chat_id, and every member can vote; voters lists each member's handle.
With GROUP_VOTES left empty, the relay doesn't wake the Bot per vote. A scheduled routine does the counting instead:
Create a routine named "Poll deadlines" that runs every day at 6 pm. Each run:
for every file in /workspace/.zerosettle/polls/ whose deadline has passed and that
isn't marked done, GET /api/imessage/messages/<poll id>/poll with the key in
/workspace/.zerosettle/token, do what the file says with the result, and mark it done.
For a group poll, post one short summary into the group with its chat_id
("7 yes, 2 maybe, see you at 9") and text me the names of the people who said yes.
The handles are phone numbers. If the Bot can read your Contacts, it can match them to names; otherwise it reports numbers. Everyone in a group must have agreed to hear from you, and a STOP from any member (a reply whose whole message or first sentence is STOP or a listed opt-out phrase) blocks sends into that group until they're removed or text START. The group chat concierge post covers when the Bot should speak in a group at all.
How does tapback-as-approval work?
The Bot never sends a sensitive message directly. It texts you a draft as one message, saves the draft under that message's id, and waits. Your tapback on that exact message is the decision:
| Your tapback | reaction | The Bot |
|---|---|---|
| π or β€οΈ | like, love | Sends the saved draft, word for word, to the saved recipient, then moves the file to drafts/sent/ |
| π | dislike | Moves the file to drafts/dropped/. Nothing is sent. |
| β | question | Texts you "What should I change?" and keeps the draft; your next text is the edit |
| Anything else | laugh, emphasize, other values | Nothing |
Then update the routine so it knows the new events. Paste this into the Bot's chat:
Update the "iMessage inbound" routine. Keep its instruction and add:
The body's "event" says what happened; no "event" means a text, handled as before.
- "poll.voted": open /workspace/.zerosettle/polls/<poll_id>.json. If there is none,
do nothing. Act on "results" (the tally now), never your own count, and only as
the file says. If "results" is null, fetch the poll with GET .../<poll_id>/poll.
- "reaction.received" (the relay sends only mine): open
/workspace/.zerosettle/drafts/<target_id>.json. If there is none, do nothing.
"like" or "love": send that draft exactly as saved, only to its saved "to" or
"chat_id", then move the file to drafts/sent/. "dislike": move it to
drafts/dropped/. "question": text me "What should I change?". Anything else:
do nothing. Never send a draft that is already in drafts/sent/.
The design is idempotent on purpose: once a draft moves to drafts/sent/, a second π or a retried webhook finds nothing to send. It's also narrow. The tapback approves one stored text to one stored recipient; it can't change the words or the recipient. Taking the π back afterwards doesn't unsend anything. ZeroSettle documents the remove_ prefix for tapbacks you send, but its docs don't spell out how a retracted tapback arrives, which is why the routine ignores any value it doesn't recognize. The approve-before-send post goes further into what should need your OK.
What isn't confirmed yet, and how to test it
- Auto Review and relayed approvals. Grok Bot's Auto Review checks each action and takes into account what you asked for in your own messages; its docs say text from somewhere else doesn't count as your request. They don't say whether a tapback forwarded by a webhook counts. Assume it doesn't: the Bot may still raise an approval card in its chat, and a card raised by a routine expires after 30 minutes. To test safely, have the Bot draft a text to your own number, tap π, and watch the Bot's chat. If a card appears, you can add a narrow "Allow automatically" custom rule, such as sending a saved draft to the recipient stored with it. Such a rule lets an action through only when the automated review finds nothing wrong, and when an "Ask first" rule also matches, "Ask first" wins.
- Who can vote. Only iOS 26 and later can vote; older iPhones show "Sent a poll". ZeroSettle's docs don't describe a way to learn someone's iOS version before you send, so write every poll so a text reply works too.
- Whether the number can send polls. A
409 poll_unsupportedmeans this number needs a software update before it can send polls. Nothing is sent. Send one test poll to your own iPhone first. - State between runs. Grok Bot's routine docs don't describe memory between runs. This pattern keeps its state in files on the Bot's cloud computer, which is shared by all your Bots, so any of them can read the drafts.
Limits and costs
| Limit | Value | Where |
|---|---|---|
| Who can get a poll | Only someone who has replied to this number (403 attachment_before_reply otherwise), or a group | ZeroSettle |
| Poll shape | 2 to 12 different options of up to 150 characters; question up to 300 | ZeroSettle |
| What a poll counts as | A message toward every limit; its question is a second message | ZeroSettle |
| Messages with known contacts | Up to 10,000 a day per number, sent and received; tapbacks aren't in the usage counts | ZeroSettle |
| New contacts | Pro: 5 a day per number. Growth: 25+ new outbound contacts a day for the starting number. Enterprise: set with sales for high volume. People who text first don't count. | ZeroSettle |
| Routine runs | Up to 200 an hour per routine, 300 an hour across all routines | Grok Bot |
| Scheduled routines | At least 5 minutes apart | Grok Bot |
Cost. ZeroSettle Pro is $49 a month for one dedicated iMessage number, with a 30-day money-back guarantee. Growth starts at $199/month for one dedicated number, with 25+ new outbound contacts a day, and a Pro number can move to it in the console. If your team needs several numbers working as one, multi-number Growth terms are agreed in an operator-priced invite; talk to us. Enterprise is custom pricing through sales for high volume and compliance needs. A number managed by the API, as here, uses credits only for videos (1 per 3 MB), not for texts, polls or tapbacks. On the Grok Bot side, each routine run spends your plan's usage, and each forwarded vote or tapback is a run. Full plan details are in ZeroSettle pricing and which plan is right for you.
Message only people who asked to hear from you; a poll is a message like any other. See iMessage and TCPA consent. If you'd rather not run a Bot and a relay at all, ZeroSettle's hosted AI agent answers texts from a business context you write, and the Grok Bot vs hosted agent post compares the two. To manage the number from Claude Code or Cursor instead, see connect Claude or Cursor with MCP.
Frequently asked questions
Can Grok Bot send an iMessage poll?
Not on its own: Grok Bot's docs list no texting tool. With a ZeroSettle number it can, because a poll is one call to the same send endpoint it uses for texts: POST /api/imessage/send with a poll object of 2 to 12 options and an optional question. It goes to someone who has already texted the number, or into a group chat with chat_id.
Does the Bot see every vote?
ZeroSettle sends a poll.voted webhook each time someone votes, changes a vote or removes one, with that person's whole selection and the poll's results as they stand. Votes can arrive out of order, so the Bot should act on the results in the event rather than keep its own count. A relay forwards each event to the Bot's webhook routine, and every forwarded vote starts a run.
Can I approve the Bot's messages with a tapback?
Yes, with the pattern in this post. The Bot texts you a draft, you tap thumbs up to send it or thumbs down to drop it, and ZeroSettle reports your tapback as reaction.received with the id of the draft. The relay forwards only your own tapbacks. Grok Bot's docs don't say whether a request relayed by webhook counts as your approval for Auto Review, so test with a draft to your own number first.
What if someone's iPhone can't vote?
People on iOS 26 or later vote right in the conversation. On older iPhones they see βSent a pollβ and can't vote. The poll's question, when you set one, follows as a text of its own (if that text can't be sent, the poll still stands), so end it with something like β(or just reply)β and have the Bot handle a text answer the same way it handles a vote.
Do polls work in iMessage group chats?
Yes. A send with chat_id and a poll puts the poll in the group, every member can vote, and voters lists each member's handle. Group votes arrive as poll.voted with the chat_id. In a big group, have the Bot check the tally at a deadline instead of waking it for every vote, and make sure everyone in the group agreed to hear from you.
Sources
- Skills and routines, Grok Bot docs: skills, webhook triggers, run limits, schedules. Checked November 4, 2026.
- Approvals, security and privacy, Grok Bot docs: Auto Review, approval cards, custom rules. Checked November 4, 2026.
- Computer and apps, Grok Bot docs: the shared cloud computer and connected apps. Checked November 2, 2026.
- Mobile, Grok Bot docs: the phone apps a Bot can use, including read-only Contacts. Checked November 2, 2026.
- Send a message, ZeroSettle iMessage API docs: polls, reactions (tapbacks), replies. Checked November 4, 2026.
- Webhooks, step by step, ZeroSettle iMessage API docs: poll.voted, reaction.received, retries. Checked November 4, 2026.
- Group chats and Errors, limits, and compliance, ZeroSettle iMessage API docs. Checked November 4, 2026.
Changelog
- First published.