Abandoned checkout follow-up over iMessage for opted-in shoppers
ZeroSettle lets a high-ticket DTC brand follow up an abandoned checkout over iMessage with one link-free question from a named teammate about the product in the cart. The cart link goes out only if the shopper replies, because a reply is what makes it tappable. The follow-up is only for shoppers whose consent to marketing texts is on record, from an unchecked-by-default box ticked at checkout or earlier. On the $49-a-month Pro plan, the limit of 5 new contacts a day per number means following up only carts above a value threshold and leaving the rest to email.
Key takeaways
- What it is: one question over iMessage, with no link, from your brand's own number to a shopper who left checkout. Your backend sends it through ZeroSettle's REST API, and the cart link follows only after the shopper replies.
- Who it fits: DTC brands selling considered purchases (orders of roughly $75 and up) whose shoppers have opted in to marketing texts.
- Main limit: 5 new contacts a day per number on Pro, so you rank carts by value. Growth allows 50 a day and 3 per 15 minutes, per number. A shopper already in a conversation with you doesn't count.
- Price: Pro is $49/month for one number. Growth, for several numbers working as one, is from $599/mo. There are no per-message fees.
- What it does not do: reach shoppers on Android (there is no SMS fallback), or send a discount to every abandoner. That is high-volume marketing, which ZeroSettle's terms prohibit.
Who uses iMessage for abandoned checkouts?
iMessage follow-up for abandoned checkouts suits DTC brands with considered purchases, orders of roughly $75 and up. Many of those shoppers leave over an unanswered question: a mattress's firmness, a jacket's sizing, whether an LED face mask suits sensitive skin, a sleeping bag's temperature rating. ZeroSettle is an iMessage API: a real phone number on real iMessage, running on Apple hardware we operate for you, that your code texts customers from and hears back on through a bearer token and signed webhooks.
A typical abandoned-cart text ("Complete your order! 10% off") reads like spam and cheapens a premium brand. ZeroSettle's version is one question from a named person about the product in the cart. It doesn't suit every store (see when ZeroSettle is not a fit), and more jobs like this one are on the ZeroSettle use-case hub.
How iMessage for abandoned checkouts works, step by step
Your store flags the abandoned checkout, and 30 to 60 minutes later (or after 8 am, for a shopper who left at night) your backend sends one question with no link through ZeroSettle. The cart link goes out only after the shopper answers.
- Consent on record. The shopper has ticked an unchecked-by-default box agreeing to marketing texts from your brand, at this checkout or an earlier one. Keep the wording, the time and the number. On Shopify, an SMS marketing preference is saved only once the customer submits the checkout (Shopify Help Center), so a box ticked in a checkout the shopper then left puts no consent on file.
- Pick the carts. A job reads the open checkouts. Shopify's Admin API returns each one with its line items, its total and an
abandonedCheckoutUrl, "the URL for the buyer to recover their checkout" (Shopify API docs). The job skips completed checkouts, carts under your value threshold, and any text that would land outside 8 am to 9 pm in the shopper's time zone. - iMessage check (automatic). ZeroSettle checks every first message to a new contact with Apple. A number that isn't on iMessage is refused with
422 not_on_imessage: nothing is sent and no new-contact slot is used. Those shoppers stay with email. - Ask one question.
POST /api/imessage/sendsends a question from a named teammate about the product in the cart, normally within 2–5 seconds, with your brand name and photo. It carries no link, because "an iPhone won't open links from an unknown sender" (ZeroSettle docs). See how to write a first iMessage customers reply to. - Answer the reply. About 1–2 seconds after the shopper replies, a signed
message.receivedevent reaches your webhook, and the thread shows in the console. A teammate in the console, or your own model on the webhook, answers the question and then sends the cart link, which is now tappable. - Stop after one follow-up. With no reply, send at most one more text. ZeroSettle enforces this on every plan: after two unanswered messages, a send returns
403 awaiting_reply.
Example conversation: an abandoned mattress checkout
The opener names a teammate and the mattress from the checkout's line items, and asks one question.
- Today 2:41 PM
- Business: Hi Chris, it's Maya at Loma. Saw you were looking at the Medium-Firm Hybrid. Any questions on firmness or delivery I can help with?
- Customer: is it ok for side sleepers? i have shoulder issues
- Business: Good question. Most side sleepers with shoulder pain do better on our Plush. Same price, 100-night trial either way.
- Business: Here's your cart switched to Plush: loma.example/c/8812
- Customer: ok doing it
A discount code wouldn't have answered Chris's question, and the answer changed which mattress he bought.
Setup: an abandoned-checkout job with a value threshold
The setup has two pieces: a job that ranks abandoned checkouts and sends the opener, and a webhook receiver that hands each reply to a person or your model.
The job: rank carts and send the opener
# cart_followup.py: run every 10 minutes from cron or your job queue
import os, requests
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
BASE = "https://portal.zerosettle.io"
TOKEN = os.environ["ZEROSETTLE_TOKEN"]
THRESHOLD = 400 # carts worth a teammate's question
def due(c, now): # c: your checkout joined to its consent record
if c.completed_at or not c.text_consent or c.asked or c.total < THRESHOLD:
return False
if not timedelta(minutes=30) <= now - c.abandoned_at <= timedelta(hours=12):
return False # too soon, or too old to ask about
return 8 <= now.astimezone(ZoneInfo(c.time_zone)).hour < 21 # 8 am to 9 pm, shopper's time
def run(checkouts):
now = datetime.now(timezone.utc)
for c in sorted((c for c in checkouts if due(c, now)), key=lambda c: c.total, reverse=True):
text = (f"Hi {c.first_name}, it's {c.teammate} at Loma. Saw you were looking at the "
f"{c.product}. Any questions on firmness or delivery I can help with?")
r = requests.post(f"{BASE}/api/imessage/send", json={"to": c.phone, "text": text},
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=90)
if r.ok:
c.mark_asked(r.json()["id"]) # one opener per checkout
continue
code = r.json()["error"]["code"]
if code == "new_contact_limit":
break # allowance used; this run's biggest carts went first
if code == "recipient_opted_out":
c.revoke_text_consent() # they texted STOP; never retry
elif code in ("awaiting_reply", "not_on_imessage", "send_failed"):
c.mark_asked(None) # 2 texts unanswered, not on iMessage, or a bad number
Each run sends its due carts biggest first, so a 429 new_contact_limit leaves only that run's smallest carts for the next run. A cart that hits line_busy or a 503 stays unmarked and goes again too. The 12-hour cap lets a cart abandoned at night go out after 8 am.
The receiver: verify, then hand off
# replies.py: run with uvicorn replies:app --port 8100
import hashlib, hmac, json, os, time
from fastapi import BackgroundTasks, FastAPI, Request, Response
SECRET = os.environ["ZEROSETTLE_WEBHOOK_SECRET"]
app = FastAPI()
seen: set[int] = set() # use your database in production
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
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_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/webhook")
async def webhook(request: Request, tasks: BackgroundTasks):
raw = await request.body()
if not verify(SECRET, request.headers.get("X-ZeroSettle-Signature", ""), raw):
return Response(status_code=401)
payload = json.loads(raw)
if payload["event"] != "message.received":
return Response(status_code=200) # your own sends echo back as message.sent
msg = payload["message"]
if msg["id"] in seen:
return Response(status_code=200) # a retry of one already handled
seen.add(msg["id"])
cart = cart_we_asked_about(msg["handle"]) # your DB: number -> checkout in the opener
if cart:
tasks.add_task(route_reply, msg, cart) # alert the teammate, or draft with your model
return Response(status_code=200)
route_reply (yours to write, like cart_we_asked_about) alerts the named teammate, who answers in the ZeroSettle console, or hands the shopper's text and the cart's line items to your model, whose answer and cart link your code sends with POST /api/imessage/send. Respond 2xx at once: ZeroSettle waits 10 seconds, then retries after 1, 5 and 20 seconds. See the docs' send a message and webhooks, step by step.
Limits and consent for abandoned checkouts
On Pro, a ZeroSettle number can open 5 new conversations a day, so abandoned-checkout follow-up is rationed by cart value. A shopper who has replied is in an ongoing conversation, which doesn't count against those limits.
| Limit | Pro | Growth |
|---|---|---|
| New contacts per day (3 AM to 3 AM Eastern) | 5 | 50 |
| New contacts per rolling 15 minutes | — | 3 |
| Unanswered messages to one shopper | 2, then 403 awaiting_reply | 2, then 403 awaiting_reply |
| Messages sent and received | Unlimited (counted) | Unlimited (counted) |
The limit math for one day of carts
Take the fictional Loma Sleep with a $400 threshold.
- A normal weekday: 12 carts qualify, more than Pro's 5. Raise the threshold until about 5 a day qualify, and leave the rest to email.
- A sale day: 45 carts qualify. Pro reaches 5 of them, and the rest stay with email. The job ranks only the carts due at each run, so the day's first 5 go out, not its 5 biggest. Raise the threshold for the sale, or book a demo to move to Growth.
- The same 45 on Growth: each number opens 3 new conversations per 15 minutes, so three numbers working as one send 9 per window. A backlog of 45 clears in about an hour, well inside each number's 50 a day.
- Existing threads: a shopper who already texts your support number isn't a new contact, but ZeroSettle's docs warn against automated marketing in a support thread.
Consent comes first
In the US, marketing texts generally need prior express written consent, and should go out between 8 am and 9 pm in the shopper's time zone, the window the FCC's TCPA rule sets for telephone solicitations. Shopify notes that its default checkbox text promises "news and offers", and a store that sends abandoned cart reminders needs to change it. More in iMessage marketing and TCPA consent. This isn't legal advice.
A text with the word STOP in it, or a clear request like “take me off your list”, opts the shopper out on every number on your account. Later sends return 403 recipient_opted_out until the shopper texts START.
When ZeroSettle is not a fit for abandoned checkouts
ZeroSettle is the wrong tool for a discount to every abandoner, or for shoppers who haven't clearly agreed to marketing texts.
- A discount text to every abandoner is high-volume marketing, which ZeroSettle's terms prohibit. Use an SMS marketing platform for that.
- Low-ticket carts where your cart email already recovers enough.
- Shoppers without explicit marketing-text consent on record, including those who ticked the box but left before it was saved.
- Shoppers on Android. There is no SMS fallback.
If shoppers text you first with product questions, text-to-buy over iMessage fits better. For app subscriptions, see paywall abandonment.
How much does iMessage for abandoned checkouts cost?
ZeroSettle charges a plan price with no per-message fees, and messages are unlimited (but counted).
| Plan | Price | Fits |
|---|---|---|
| Pro | $49/month | One number, 5 new shoppers a day, unlimited messages with people who message you first, self-serve, no setup fee |
| Growth | From $599/mo | Several numbers working as one, 50 new contacts a day per number, set up after a demo |
Both plans carry a 30-day money-back guarantee on the subscription. Compare them in ZeroSettle's plans, or start Pro from Get started.
Frequently asked questions
Is it legal to text shoppers about abandoned carts?
In the US, marketing texts generally need prior express written consent, and ZeroSettle's docs note TCPA damages of $500 to $1,500 per message sent without consent. The FCC's definition of that consent says agreeing can't be a condition of purchase, so the checkbox should be optional. This isn't legal advice.
Why no link in the first text?
An iPhone shows links from unknown senders as plain text until the shopper replies or saves your number; ZeroSettle sends the link anyway and returns warning.code: "link_not_tappable". With Screen Unknown Senders on, a first text is "filtered into another folder" without a notification (Apple Support), so silence isn't always a no. See why links in a first iMessage aren't tappable.
How many carts can I follow up per day?
On Pro, 5 new shoppers a day per number. On Growth, 50 a day per number and 3 per 15 minutes, and each new shopper goes to the number with the most room left that day. The day resets at 3 AM Eastern.
Should I lead with a discount?
No. Lead with a real question about the product. An offer can follow if it answers the shopper's objection, such as the delivery cost of a large item.
Sources
- ZeroSettle iMessage API docs: "Send a message" (links in a first message), "Customer support", "Webhooks, step by step" and "Errors, limits, and compliance". Checked October 12, 2026.
- ZeroSettle iMessage API Terms of Service, section 13. Last modified September 28, 2026.
- Collecting customer contact information, Shopify Help Center. Checked October 8, 2026.
- AbandonedCheckout object, Shopify GraphQL Admin API reference. Checked October 8, 2026.
- Screen and filter text messages on iPhone, iPhone User Guide, Apple Support. Checked October 8, 2026.
- 47 CFR § 64.1200, Delivery restrictions, paragraphs (c)(1) and (f)(9), the FCC's TCPA rule (Legal Information Institute, Cornell Law School). Checked October 8, 2026.