Use cases

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.

  1. 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.
  2. 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.
  3. Ask, without a link. POST /api/imessage/send sends 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).
  4. Send the link after they agree. The reply reaches your webhook as message.received about 1–2 seconds after it lands on the line. Your handler sends the billing link, which is tappable now that the customer has replied.
  5. Confirm with a tapback. When invoice.paid arrives for that invoice, POST /api/imessage/reactions puts a like on 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.
  6. 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.

The link should fix the card for the next renewal as well as this one, and Stripe's options differ:

Stripe billing links for failed-payment texts (Stripe docs, checked October 8, 2026)
LinkCustomer canWatch out for
Customer portal sessionUpdate the card and pay the open invoiceA new session expires after 5 minutes unless opened, so text a link to your own URL that creates the session when tapped.
Hosted invoice pagePay the failed invoiceThe 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 pageWhatever you buildUpdate 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.

  1. Today 9:12 AM
  2. 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?
  3. Customer: oh yeah new card. send it
  4. Business: Here you go, takes 30 seconds: billing.tallyroom.example/update
  5. Customer: done👍
  6. Business: Got it, you're all set.
Illustrative example with a fictional subscription business, Tallyroom. Blue bubbles are sent from Tallyroom's number; gray bubbles are the customer's. The 👍 is Tallyroom's tapback once the invoice was paid.

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.

python
# 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.

ZeroSettle limits for failed-payment texts, per number (API docs, checked October 12, 2026)
LimitProGrowth
New contacts per day (3 AM to 3 AM Eastern)550
New contacts per rolling 15 minutes—3
Unanswered messages to one customer2, then 403 awaiting_reply2, 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).

ZeroSettle plans (zerosettle.io, checked October 12, 2026)
PlanPriceFits
Pro$49/monthOne number, 5 new contacts a day, unlimited messages with people who message you first, self-serve, no setup fee
GrowthFrom $599/mo3+ 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

  1. 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.
  2. ZeroSettle iMessage API Terms of Service, section 13. Last modified September 28, 2026.
  3. Using webhooks with subscriptions, Stripe Docs (invoice.payment_failed, invoice.paid). Checked October 8, 2026.
  4. The Invoice object, Stripe API Reference (billing_reason, attempt_count). Checked October 8, 2026.
  5. Automate payment retries, Stripe Docs (Smart Retries, attempt_count, hard decline codes, which payment method to update). Checked October 8, 2026.
  6. How cards work, Stripe Docs (automatic card updates). Checked October 8, 2026.
  7. Provide a customer portal to your customers, Stripe Docs (features, session expiry). Checked October 8, 2026.
  8. Hosted invoice page, Stripe Docs (payment method persistence). Checked October 8, 2026.
  9. Can unencrypted PANs be sent over e-mail, instant messaging, SMS, or chat?, PCI Security Standards Council FAQ 1085. Checked October 8, 2026.
  10. Reducing involuntary subscriber churn, Apple Developer Documentation (billing retry for up to 60 days, the Manage Payments page). Checked October 8, 2026.

Written by the ZeroSettle team. 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.

Product facts on this page come from the ZeroSettle iMessage API docs, zerosettle.io and the iMessage API Terms as of October 12, 2026. Stripe, PCI Security Standards Council and Apple details come from the pages listed under Sources, checked the same day. This page is not legal or compliance advice.

Text your customers from your own iMessage number

ZeroSettle gives your business its own phone number on real iMessage, running on Apple hardware we operate for you in a server facility. Your code sends with one API call and hears every reply on a signed webhook.

Pro is $49 a month with a 30-day money-back guarantee. Growth, for several numbers working as one, is from $599/mo.