Recover failed payments over iMessage: ask first, then send the link
ZeroSettle lets a subscription business recover a declined renewal over iMessage. Your server texts the customer a heads-up with no link from your own number ("The card ending 4021 was declined for your renewal. Want me to send a secure link to update it?"), then sends the billing-page link once they say yes, when it is tappable. When the payment goes through, a 👍 tapback on their reply confirms it. Only customers who agreed to account texts qualify, and the thread never asks for a card number.
Key takeaways
- What it is: a short thread from your business's own iMessage number, sent by your server through ZeroSettle's REST API: a heads-up with no link when a renewal fails, the billing-page link after the customer replies, and a tapback once the invoice is paid.
- Who it fits: subscription businesses that bill cards through Stripe or a similar system, have a card-update page to link, and whose customers agreed to account texts.
- Main limit: on Pro, a heads-up to a customer your number has never messaged is one of 5 new contacts a day. The link, a follow-up and the tapback don'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 customers on Android, because there is no SMS fallback. It never takes card numbers either: the card is entered on your billing page, never in the thread.
Who uses iMessage for failed payments?
iMessage failed-payment recovery fits subscription businesses that charge a saved card: SaaS, apps with web billing, memberships and subscription boxes. 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.
The problem is involuntary churn: an expired or declined card cancels a customer who never meant to leave. A retry charges the same card again, and after a hard decline, such as a lost or stolen card, Stripe's docs say it can't retry the invoice without a new payment method. Only the customer can enter one. More retention jobs are on the ZeroSettle use-case hub.
How iMessage for failed payments works, step by step
A failed-payment event triggers one question with no link. The link follows the customer's answer, and the paid event closes the thread with a tapback.
- Check consent. Stripe fires
invoice.payment_failed, and most billing systems have an equivalent event. Your handler checks that the customer agreed to account notifications by text. Customers with no record get the email instead. - Send one heads-up per invoice. Stripe sends the same event for retry attempts, with an
attempt_count, and its recommended Smart Retries default is 8 tries within 2 weeks. Key the heads-up on the invoice ID so the customer gets one text per invoice, not one per retry. - Ask, without a link.
POST /api/imessage/sendsends the question. Your brand name and photo, if you've set them, go with the first message, and the card's last four digits tell the customer which account you mean. In ZeroSettle's docs' words, "an iPhone won't open links from an unknown sender" until they reply or save your number (why first-message links aren't tappable). - Send the link after they agree. The reply reaches your webhook as
message.receivedabout 1–2 seconds after it lands on the line. Your handler sends the billing link, which is tappable now that the customer has replied. - Confirm with a tapback. When
invoice.paidarrives for that invoice,POST /api/imessage/reactionsputs alikeon the customer's last message and sends one line of confirmation. If they updated the card without paying the invoice, that event waits for Stripe's next retry. If they paid without replying, send nothing. - Follow up once, at most. If there's no reply after a couple of days and the invoice is still open, send one more text with no link. Then leave the rest to email.
Which Stripe link to send
The link should fix the card for the next renewal as well as this one, and Stripe's options differ:
| Link | Customer can | Watch out for |
|---|---|---|
| Customer portal session | Update the card and pay the open invoice | A new session expires after 5 minutes unless opened, so text a link to your own URL that creates the session when tapped. |
| Hosted invoice page | Pay the failed invoice | The new card isn't saved if the customer already has a default for invoices, so the next renewal may try the old card. |
| Your own billing page | Whatever you build | Update the field the failed payment used: if the subscription has its own default_payment_method, Stripe keeps retrying that one. |
Example conversation: failed payments
The customer agrees, updates the card, and gets a tapback on "done" when Stripe reports the invoice paid.
- Today 9:12 AM
- Business: Hi Jamie, heads up: the card ending 4021 was declined for your Tallyroom renewal. Want me to send a secure link to update it?
- Customer: oh yeah new card. send it
- Business: Here you go, takes 30 seconds: billing.tallyroom.example/update
- Customer: done👍
- Business: Got it, you're all set.
Setup: invoice.payment_failed to a heads-up, invoice.paid to a tapback
One small service handles two webhooks: Stripe's opens and closes each case, and ZeroSettle's carries the reply. The ZeroSettle calls and signature check follow the ZeroSettle iMessage API docs.
# dunning.py: run with uvicorn dunning:app --port 8100
import hashlib, hmac, json, os, re, time
import requests, stripe
from fastapi import BackgroundTasks, FastAPI, Request, Response
BASE = "https://portal.zerosettle.io"
TOKEN = os.environ["ZEROSETTLE_TOKEN"]
ZS_SECRET = os.environ["ZEROSETTLE_WEBHOOK_SECRET"]
STRIPE_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
YES = re.compile(r"\b(yes|yeah|yep|sure|ok|okay|send)\b", re.I)
NO = re.compile(r"\b(no|nope|don['’]?t|stop)\b", re.I)
app = FastAPI()
seen: set[int] = set() # use your database in production
def zs(path: str, body: dict) -> dict: # branch on error.code; queue a 429 for later
return requests.post(f"{BASE}{path}", json=body, timeout=90,
headers={"Authorization": f"Bearer {TOKEN}"}).json()
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("/stripe")
async def stripe_webhook(request: Request, tasks: BackgroundTasks):
raw = await request.body()
try:
event = stripe.Webhook.construct_event(
raw, request.headers.get("Stripe-Signature", ""), STRIPE_SECRET)
except Exception:
return Response(status_code=400)
handler = {"invoice.payment_failed": heads_up, "invoice.paid": paid}.get(event["type"])
if handler:
tasks.add_task(handler, event["data"]["object"])
return {"ok": True}
def heads_up(invoice: dict) -> None:
if invoice.get("billing_reason") != "subscription_cycle":
return # a failed signup, not a renewal
c = customer_for(invoice["customer"]) # your DB: number, text consent, card
if not c or not c.account_texts_ok or c.open_invoice == invoice["id"]:
return # no consent, or a retry we already asked about
c.open_case(invoice["id"])
zs("/api/imessage/send", {"to": c.phone, "text": f"Hi {c.first_name}, heads up: the card "
f"ending {c.card_last4} was declined for your Tallyroom renewal. "
"Want me to send a secure link to update it?"})
def paid(invoice: dict) -> None:
c = customer_for(invoice["customer"])
if not c or c.open_invoice != invoice["id"]:
return # an ordinary renewal: say nothing
if c.last_reply_id: # only if they answered the heads-up
zs("/api/imessage/reactions", {"message_id": c.last_reply_id, "reaction": "like"})
zs("/api/imessage/send", {"to": c.phone, "text": "Got it, you're all set."})
c.close_case()
@app.post("/zerosettle")
async def zerosettle_webhook(request: Request, tasks: BackgroundTasks):
raw = await request.body()
if not verify(ZS_SECRET, request.headers.get("X-ZeroSettle-Signature", ""), raw):
return Response(status_code=401)
payload = json.loads(raw)
msg = payload.get("message") or {}
if payload["event"] == "message.received" and msg["id"] not in seen:
seen.add(msg["id"]) # retries carry the same id
tasks.add_task(on_reply, msg)
return Response(status_code=200)
def on_reply(msg: dict) -> None:
c = customer_for_number(msg["handle"]) # your DB: number -> open case
if not c or not c.open_invoice:
return
c.last_reply_id = msg["id"] # the bubble the tapback goes on
text = msg.get("text") or ""
if not c.link_sent and YES.search(text) and not NO.search(text):
c.link_sent = True # your URL; it opens a portal session on tap
zs("/api/imessage/send", {"to": msg["handle"],
"text": f"Here you go, takes 30 seconds: {c.billing_link()}"})
else:
notify_billing_team(msg) # a "no", a question, anything after the link
Register the receiver with PUT /api/imessage/webhook and store the signing secret, which is shown once. ZeroSettle waits 10 seconds for a 2xx, then retries after 1, 5 and 20 seconds. See the docs' send a message and webhooks, step by step.
Limits and consent for failed payments
Failed-payment texts use little of ZeroSettle's allowance. The heads-up counts as a new contact only if your number has never messaged that customer. Every later text to them is an ongoing conversation, which the limit never counts, so the link, the follow-up and next year's heads-up use no new-contact slot.
| 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 customer | 2, then 403 awaiting_reply | 2, then 403 awaiting_reply |
"A 'new contact' is a handle the line delivers a text, voice message, or photo to first; reactions, typing, profile shares, and failed sends never count." (ZeroSettle docs)
The limit math: anniversary vs calendar billing
- Anniversary billing spreads renewals across the month, so failures arrive a few a day, usually within Pro's 5.
- Calendar billing on the 1st puts every failure on one morning. Say 45 renewals fail, all for customers your number has never messaged. Pro sends 5 and hits the daily cap. The allowance resets at 3 AM Eastern, so the other 40 go out 5 a day over the next eight days, at a sensible hour where each customer lives; email covers the wait better. Check that each queued invoice is still unpaid first, because a retry may have succeeded.
- On Growth, three numbers working as one send 9 per 15 minutes and clear all 45 in a little over an hour.
Consent comes first
Text only customers who agreed to account notifications by text, with a record you can show. Your first message says who you are. A text with the word STOP in it, or a clear request like “take me off your list”, opts the customer out on every number on your account, and only START opts them back in. After that, sends and tapbacks return 403 recipient_opted_out, so email carries the rest (how STOP works). This isn’t legal advice.
Never ask for card numbers in the thread
The card is entered only on the billing page. The PCI Security Standards Council says Requirement 4.2.2 prohibits sending unprotected card numbers "via end-user messaging technologies", SMS and chat included. ZeroSettle's terms say content "may not be encrypted" and ZeroSettle "cannot guarantee communication privacy." See how ZeroSettle secures its API and webhooks.
When ZeroSettle is not a fit for failed payments
ZeroSettle is the wrong tool for failed payments when any of these is true:
- Customers didn't agree to account texts. A billing relationship alone isn't a consent record.
- You have no card-update page to link. Without your billing provider's page or your own, there is nothing safe to send after the yes.
- Many customers are on Android. "The address must be on someone's iMessage; there is no SMS or email fallback." Keep email dunning for them.
- You'd collect card numbers by text. Never.
- The App Store bills the subscription. Apple's docs say the App Store retries billing for up to 60 days, and the fix happens on the Manage Payments page in the customer's account settings, not on a billing page of yours.
Before a renewal fails, trial-ending and renewal reminders or a keep, skip or swap question can head it off. For customers leaving on purpose, see texting the founder from your cancel flow.
How much does iMessage for failed payments cost?
Failed-payment recovery costs the plan price. ZeroSettle charges no per-message fees, and messages are unlimited (but counted).
| Plan | Price | Fits |
|---|---|---|
| Pro | $49/month | One number, 5 new contacts a day, unlimited messages with people who message you first, self-serve, no setup fee |
| Growth | From $599/mo | 3+ 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, and this flow uses no AI credits. Compare ZeroSettle's plans or start Pro from Get started.
Frequently asked questions
What if my code puts the link in the first text anyway?
It still goes out, and the send response adds warning.code: "link_not_tappable". The customer sees plain text they can't tap, so ask first.
Can automatic retries fix an expired card?
Sometimes. Stripe tries to update saved cards through the card networks when a bank reissues one, where the issuer takes part. Otherwise a retry charges the same dead card, and only the customer can enter a new one.
Is a failed-payment text marketing?
Usually not: a declined-card notice is generally treated as a transactional account message, though the rules depend on wording and jurisdiction. Keep it to one heads-up and one follow-up, and leave discounts out: an offer can turn it into marketing, which needs its own consent. This isn't legal advice.
What if a customer texts their card number?
Don't repeat it: point them to the billing page and scrub it from your webhook logs. Once the case is closed, DELETE /api/imessage/conversations/{handle} removes every stored row for that handle, though "nothing already delivered is unsent", and your next text to them counts as a new contact again.
Sources
- ZeroSettle iMessage API docs: "Send a message" (links in a first message, reactions), "Read messages" (delete a conversation), "Webhooks, step by step" and "Errors, limits, and compliance". Checked October 8, 2026.
- ZeroSettle iMessage API Terms of Service, section 13. Last modified September 28, 2026.
- Using webhooks with subscriptions, Stripe Docs (
invoice.payment_failed,invoice.paid). Checked October 8, 2026. - The Invoice object, Stripe API Reference (
billing_reason,attempt_count). Checked October 8, 2026. - Automate payment retries, Stripe Docs (Smart Retries,
attempt_count, hard decline codes, which payment method to update). Checked October 8, 2026. - How cards work, Stripe Docs (automatic card updates). Checked October 8, 2026.
- Provide a customer portal to your customers, Stripe Docs (features, session expiry). Checked October 8, 2026.
- Hosted invoice page, Stripe Docs (payment method persistence). Checked October 8, 2026.
- Can unencrypted PANs be sent over e-mail, instant messaging, SMS, or chat?, PCI Security Standards Council FAQ 1085. Checked October 8, 2026.
- Reducing involuntary subscriber churn, Apple Developer Documentation (billing retry for up to 60 days, the Manage Payments page). Checked October 8, 2026.