Evalgent
Back to Blog
Voice AI Testing

Stripe AI Phone Payments: Retries, Webhooks, Declines

Deepesh Jayal
16 min read
Stripe AI Phone Payments: Retries, Webhooks, Declines
On this page

Last verified November 5, 2026, against the Stripe pages linked below. Re-check limits before you rely on them. Collecting raw card numbers on a call is out of scope here. This is engineering guidance, not legal or PCI advice.

A voice agent does not just "call Stripe." It turns a spoken yes into a chain of Stripe objects, retries and events. Each link can fail while the call sounds fine. This post covers Stripe AI phone payments for teams that built their own agent inside their own product. It is part of a series on what breaks at the voice and system boundary.

24 hours
Minimum API v1 idempotency key retention (Stripe docs)
30 days
API v2 idempotent replay window (Stripe docs)
3 days
Live-mode webhook retry period (Stripe docs)
5 to 7 days
Card-not-present authorization validity (Stripe docs)

Where a phone payment breaks

The transcript of a failed Stripe AI phone payments call can look clean. The agent said the right words in the right order. The damage sits in the Stripe record. Six seams matter:

  • Amount. The amount spoken differs from the amount sent.
  • Retry. A timeout leads to a second request with a new key.
  • Link. The SMS payment link goes to a mis-heard number.
  • Confirmation. The agent says "paid" before any event arrives.
  • Status. The agent reads a bank step or a hold as a decline or a charge.
  • Refund. The agent promises a date that Stripe never promised.

Checking the system record is the core idea behind integration testing and workflow testing. The endpoints below are the `/v1` paths that the Stripe pages I opened document for each PaymentIntent, Checkout Session and refund.

Idempotency: one key per confirmed amount

Stripe's idempotent requests page says the API supports idempotency "for safely retrying requests without accidentally performing the same operation twice." You send the key in the `Idempotency-Key` header. Stripe's advanced error handling page says to use it on all API v1 `POST` requests.

What Stripe documents for API v1, as of November 2026:

  • Stripe saves the status code and body of the first request made with a key.
  • "Retries with the same key return the saved response, including `500` errors."
  • Keys are up to 255 characters long.
  • Stripe retains keys for at least 24 hours and can remove them after that.
  • The same key with different parameters returns an error.
  • Failures before execution begins, such as invalid parameters or rate limiting, cache nothing.

API v2 differs. Per Stripe's API v2 overview, a replay needs the same key, API and account or sandbox, within 30 days. v2 also retries failed work instead of replaying the first error. Do not assume v2 behavior for a v1 endpoint. Design for the stricter v1 rules.

The key design rule

Generate the key once, when the caller confirms the amount. Store it before you call Stripe. Reuse it for every retry of that payment. Mint a new key only when the caller changes the amount or the purchase. Three habits break this:

1. A key per HTTP call. A client that mints a fresh UUID each attempt defeats the key. Stripe says to "generate and persist a key for the operation" if you retry across SDK calls or restarts.

2. A key per tool call. The model may call `charge_card` twice in one turn. The key must come from your attempt record, never from the model.

3. A key hashed from wording. "Eighty-four dollars" and "$84" would give two keys for one payment. Key on your attempt ID.

Stripe suggests keying on an object like a cart ID. Avoid personal data in keys.

Timeline of two retry designs after a timeout: a reused idempotency key returns the saved result, while a new key creates a second payment.

Worked example: the cost of a new key per retry

This is an illustrative assumption, not a measurement. Say the agent handles 400 payment calls a day. Say 0.5% hit an ambiguous timeout, which is 2 a day. Say the first request had succeeded in half of them. A new key per retry then makes 1 duplicate charge a day, or 30 a month. At an $84 ticket, that is $2,520 a month to refund. A persisted Stripe idempotency key per attempt produces none.

Code: create a Checkout Session with a persisted key

A simplified sketch over plain HTTP, following Stripe's Checkout Session reference.

import os, time, uuid, requests

STRIPE = "https://api.stripe.com/v1"
AUTH = (os.environ["STRIPE_SECRET_KEY"], "")  # use a restricted key

def send_payment_link(db, call_id: str, price_id: str):
    # One attempt row per confirmed amount. The key is minted once and stored.
    attempt = db.get_or_create_attempt(call_id=call_id, price_id=price_id,
                                       idem_key=str(uuid.uuid4()),
                                       expires_at=int(time.time()) + 3600)
    try:
        resp = requests.post(
            f"{STRIPE}/checkout/sessions",
            auth=AUTH,
            headers={"Idempotency-Key": attempt.idem_key},
            data={
                "mode": "payment",
                "line_items[0][price]": price_id,   # a Price matching the confirmed amount
                "line_items[0][quantity]": 1,
                "client_reference_id": call_id,
                "metadata[call_id]": call_id,
                "metadata[attempt_id]": attempt.id,
                "payment_intent_data[metadata][call_id]": call_id,
                "payment_intent_data[metadata][attempt_id]": attempt.id,
                "expires_at": attempt.expires_at,   # stored once, so retries send identical params
                "success_url": "https://example.com/paid?session_id={CHECKOUT_SESSION_ID}",
            },
            timeout=8,
        )
    except (requests.Timeout, requests.ConnectionError):
        db.mark(attempt, "unknown")        # outcome unknown: retry SAME key, SAME params
        return {"status": "retry_later", "attempt": attempt.id}

    if resp.status_code == 409 or resp.status_code >= 500:
        db.mark(attempt, "unknown")        # do not mint a new key
        return {"status": "retry_later", "attempt": attempt.id}
    resp.raise_for_status()
    session = resp.json()
    db.mark(attempt, "link_created", session_id=session["id"])
    return {"status": "link_created", "url": session["url"]}

For a `409`, Stripe says to wait and retry "with that key and the same parameters." For a `500`, treat the outcome as indeterminate. Do not retry with a new key "until you’ve resolved the original outcome."

Put the call ID on every Stripe object

Stripe's metadata page allows up to 50 keys, with key names up to 40 characters and values up to 500. The error handling page gives the reason that matters here. Include a local identifier so you can match a webhook object to your records, "including after reconciliation." Store the call ID, your attempt ID and the tenant ID. The post-call write-back testing post explains why the same join matters for CRM records.

One hard rule: "Don’t store any sensitive information (bank account numbers, card details, and so on) as metadata or in the `description` parameter." Never let the model write free-form call notes into metadata.

You can text a Stripe-hosted link, or collect card details by voice or keypad. This post covers the link. Card collection pulls telephony, recordings and logs into card-data scope. The AI voice agent payments guide covers PCI scope and DTMF, so I will not repeat it. Voice agent payments through a link keep the digits off the call.

Stripe's integration security guide says handling sensitive card data directly "might" require meeting "more than 300 security controls in PCI DSS." It points to integrations that send data "directly to Stripe without it passing through your servers." A hosted link is that kind. Your assessor still decides your scope.

A reusable Stripe payment link is shareable by design. Stripe's comparison table lists its customer as "Anyone with the link." It can be reused with many customers, and Stripe documents a limit on the number of purchases. If a caller forwards the SMS payment link, someone else can pay it.

A per-call Checkout Session fits a quoted amount better. Per the Checkout Session reference, `expires_at` can be 30 minutes to 24 hours after creation. The default is 24 hours. `client_reference_id` takes up to 200 characters. Set a short expiry so yesterday's link cannot pay today's order.

The wrong-number problem

The link goes to whatever number the agent captured. One mis-heard digit sends a payment page to a stranger, and the caller waits for a text that never arrives. Phone numbers are a classic alphanumeric failure, and the alphanumeric capture post shows how to read them back. Confirm the number before you create the session.

Webhooks are the only honest confirmation

For Stripe AI phone payments, the webhook is the record of truth. Stripe's fulfillment guide says you "can’t rely on triggering fulfillment only from your checkout landing page, because it’s not guaranteed customers visit that page." A caller can pay, lose signal and never return. Speech follows the same rule. The agent should not say "you're paid" because the link exists, or because the caller said "done." It should say so after your server verifies and processes the Stripe webhook.

Sequence of a texted payment link: the caller pays, Stripe sends a webhook, and only then may the agent confirm payment.

As of November 2026, Stripe's webhooks page recommends thin events for new integrations. The payment events here are snapshot events, which carry `data.object`. The page also says:

  • Stripe retries delivery "for up to three days with an exponential back off in live mode."
  • "Stripe doesn’t guarantee the delivery of events in the order that they’re generated."
  • "Webhook endpoints might occasionally receive the same event more than once." Log processed event ID values.
  • Sometimes two separate Event objects are sent. Compare the `data.object` ID and the event type.
  • Return a `2xx` before "any complex logic that could cause a timeout."
  • Stripe "requires the raw body of the request to perform signature verification."
  • Libraries default to a 5 minute tolerance. "Don’t use a tolerance value of `0`."
  • Snapshot events record `created` in seconds. Do not use it to order events or spot duplicates.

Code: verify, de-duplicate, acknowledge

Stripe recommends its official libraries. This simplified sketch follows Stripe's manual steps, so each webhook signature rule is visible. It signs the timestamp, a dot and the raw body with HMAC-SHA256.

import hashlib, hmac, json, time
from flask import Flask, request

app = Flask(__name__)
TOLERANCE_SECONDS = 300  # Stripe's libraries default to 5 minutes

def verify(raw: bytes, header: str, secret: str) -> bool:
    ts, sigs = None, []
    for item in header.split(","):
        key, _, value = item.partition("=")
        if key == "t":
            ts = value
        elif key == "v1":          # ignore every other scheme
            sigs.append(value)
    if ts is None or not sigs:
        return False
    expected = hmac.new(secret.encode(), ts.encode() + b"." + raw,
                        hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(expected, s) for s in sigs):
        return False
    return abs(time.time() - int(ts)) <= TOLERANCE_SECONDS

@app.post("/stripe/webhook")
def webhook():
    raw = request.get_data()                       # raw bytes, untouched
    if not verify(raw, request.headers.get("Stripe-Signature", ""), WEBHOOK_SECRET):
        return "", 400
    event = json.loads(raw)
    obj = event["data"]["object"]
    first_time = db.insert_event_once(event["id"], event["type"], obj.get("id"))
    if first_time:
        queue.enqueue(process_event, event["id"])  # heavy work happens off-request
    return "", 200                                  # ack fast; duplicates also get 200

The worker should re-read the object from Stripe and act on that state. Stripe says fulfillment must handle "being called multiple times with the same Checkout Session ID." Record fulfillment per session. If you ship a kill switch for payment tools, make it pause this worker too.

Map Stripe state to what the agent may say

The model needs a short list of allowed sentences per state. Stripe's PaymentIntent lifecycle page defines the states.

Matrix of six PaymentIntent states, each with a sentence the voice agent may say and one it must not say.

Two lifecycle details cause real mistakes. First, after a failed attempt, "the PaymentIntent’s status returns to `requires_payment_method` so that the payment can be retried." An agent must read the last error before it says nothing happened. Second, cancellation "can’t be undone." An agent that cancels on a vague "never mind" has closed that payment for good.

Keep the mapping in code, not the prompt. Your tool returns a small enum such as `paid`, `needs_bank_step`, `pending`, `hold_placed`, `declined_try_other` or `cancelled`. The model never sees raw statuses or decline codes.

Authorize now, capture later

Deposits and orders priced after the call often use an authorization hold. Stripe's page on placing a hold says authorizing "guarantees the amount by holding it on the customer’s payment method." You set `capture_method` to `manual`, and the PaymentIntent moves to `requires_capture`. That is manual capture. As of November 2026, Stripe documents these card-not-present windows:

Card brandMerchant-initiatedCustomer-initiated
Visa5 days (4 days 18 hours exactly)7 days
Mastercard, American Express, Discover7 days7 days

Stripe classifies payments by "signals of cardholder participation, not solely on API parameters," so your code cannot know the window. Read `capture_before` on the charge. If the window lapses, "the funds are released and the payment status changes to `canceled`."

Three more rules matter on a phone flow:

  • A partial capture "automatically releases the remaining amount." Most payments allow one capture, and certain online card payments can capture more than the authorized amount, so enforce the quoted amount as your own limit in code.
  • The `payment_intent.amount_capturable_updated` event is your "hold placed" signal.
  • To undo a hold, cancel the PaymentIntent. A charge on a `requires_capture` PaymentIntent "can’t be refunded directly."

If an agent tells a gym member "you've been charged" when only a hold exists, the member calls back about a pending line. The failed gym payment calls and self-storage reservation errors posts show how that reaches a human queue.

Saved cards and SetupIntents

A SetupIntent saves a payment method without a charge. Stripe's save-and-reuse guide says it suits charging customers "in the future—when they’re offline." That fits autopay and payment plans. The guide puts compliance on you. It says to "explicitly collect consent from the customer" and gives a checkbox, "Save my payment method for future use," as an example.

A call has no checkbox. Before you store a saved card, you need a spoken consent step and a timestamped record tied to the call ID. The recording consent post covers the recording side. Off-session charges can also fail with an `authentication_required` decline, with no caller on the line. Mortgage servicing and payment arrangement agents face this directly. The collections metrics post shows what to track.

Declines: what the agent may say

Stripe's decline code table gives next steps per code. The last column below is our recommendation, not Stripe text.

Stripe decline codeStripe's documented next stepSafe agent behavior
`insufficient_funds`Use an alternative payment methodOffer another method
`expired_card`, `incorrect_cvc`Use another card or correct the detailPoint to the payment page
`authentication_required`Authenticate, such as with 3D SecureAsk the caller to finish the step
`duplicate_transaction`"Check to see if a recent payment already exists"Look up the earlier payment first
`fraudulent`, `lost_card`, `stolen_card`Present as `generic_decline`Say only that the bank declined it
`generic_decline`, `do_not_honor`Contact the card issuerSay the bank gave no reason

The trap is the fifth row. For `lost_card`, Stripe says the specific reason "shouldn’t be reported to the customer." If your tool passes the raw code into the model's context, the model can read it aloud. Translate Stripe decline codes into your own enum first.

Stripe's card decline page adds that card networks limit how often you can reattempt a charge. Stripe "recommends a maximum of eight retries for charges that permit retries." Stripe gives that figure in the context of retry schedules. A voice agent should stop sooner. Two failed tries, then another method or a person, is a sane design to test. Log the `advice_code` too, and honor `do_not_try_again`.

Refunds and what the agent can promise

Callers ask for a Stripe refund. Stripe's refund page sets the limits.

  • You can refund a charge more than once, "but you can’t refund a total greater than the original charge amount."
  • "Refunds can only be sent back to the original payment method used in a charge." The agent cannot offer a different card.
  • Cancelling before completion costs nothing. A refund after success "might incur a fee."
  • A refund can fail. Stripe sends `refund.failed`, and you "need to arrange an alternative way to provide your customer with a refund."
  • "Only Dashboard cancellations are currently supported for card refunds."

The last point shapes your rollback design. A refund issued by API is hard to undo by API. Treat it as one-way: confirm the amount, require a reason, set a cap and log the approver. The permissions post covers scoping that tool.

Do not let the agent promise a date

The refund page gives two timing statements. One says refunds appear "in real time, depending on the card network and issuing bank." Another says a credit appears "approximately 5-10 business days later, depending upon the bank." The page gives different timing for different cases, so promise neither. Have the agent say the refund was submitted and the bank controls timing.

Rate limits and lock timeouts

Stripe's rate limit page lists 100 requests per second globally in live mode, as of November 2026. One phone line will not reach that. Concurrency on one object is likelier. Stripe documents a `429` with code `lock_timeout` and advises you to "make your requests serially or at a lower rate." Parallel tool calls at one Stripe PaymentIntent can trigger it. Queue mutations per PaymentIntent. The API rate limits post covers backoff, and the HubSpot voice agent post covers the CRM write that follows.

How to test Stripe AI phone payments before every prompt change

A prompt tweak can change when the model calls a tool, how it reads back an amount and what it says on a decline. Manual calls cannot cover that.

1. List the money-moving tools. For each, write the allowed Stripe statuses and the sentences the agent may say.

2. Put fault injection in front of a Stripe sandbox. Use a thin stub in your own stack to force timeouts, `409`, `5xx`, duplicate webhooks and out-of-order webhooks. Stripe notes that sandbox limits and latency differ from live mode, so use the stub for load.

3. Write scenarios from the prompt and flows. Cover amount changes after confirmation, hang-ups before payment, declines, expired holds and refund requests. Evalgent generates test scenarios from an agent's prompt and flows, including states, branches, policies and hard constraints, and flags coverage gaps.

4. Vary the caller. Use accents, fast speech, interruptions during the amount read-back and background noise. Run the calls over PSTN, SIP or WebRTC, whichever your agent uses in production.

5. Assert on three records. Compare what the agent said, what your tool sent to Stripe and what Stripe stored. Your harness reads Stripe and your database. Evalgent judges the call audio, and its custom metrics run on every call, so a check like "spoken amount matches confirmed amount" is possible. Evalgent does not connect to Stripe here.

6. Gate each release on the matrix below. Treat any "paid" before a verified event as a blocker.

7. Monitor production calls. Evalgent's production monitoring transcribes pushed recordings, maps them to scenarios and flags tool-call failures and policy failures with fix recommendations. Add a daily reconciliation of Stripe objects against call IDs.

Fault matrix to run

Fault injectedExpected resultFails if
Timeout after request sentSame key retried; one sessionTwo sessions or charges
`500` from StripeOutcome unknown; reconcile firstNew key minted
Same webhook twiceOne fulfillment, one SMSTwo texts or CRM writes
Events out of orderFinal state correctStale state wins
Link paid after hang-upText after verified eventNo text, or text too early
`stolen_card` decline"The bank declined it"Agent repeats the code
Hold expiresAgent offers a new paymentAgent reports a charge
One wrong digit in numberRead-back catches itLink sent unconfirmed

A payment bug has a wide blast radius. Cap per-call amounts and per-hour totals in code.

Frequently asked questions

Can an AI voice agent take card payments over the phone with Stripe?

Yes, in two ways. The agent can text a Stripe-hosted link, or collect card details itself. The link keeps card data off the call. Collecting digits by voice or keypad changes your PCI scope and is outside this post. Stripe's security guide describes lower-risk integrations that avoid sensitive card data on your servers.

How do you prevent duplicate charges when a voice agent retries a Stripe payment?

Mint one idempotency key when the caller confirms the amount, store it and reuse it on every retry. Send it in the `Idempotency-Key` header. Never let the model or HTTP client create a key per attempt. Treat a `duplicate_transaction` decline as a prompt to look up the earlier payment.

For most in-house agents, send the link. Card data stays on Stripe's hosted page, so the agent never hears or logs digits. The risk moves to the phone number, so read it back first. Prefer a per-call Checkout Session with a short expiry over a reusable Payment Link that anyone can pay.

How long does a Stripe idempotency key last?

For API v1, Stripe retains keys for at least 24 hours and can remove them after that. Reusing a removed key can run a new request. For API v2, a replay counts within 30 days, with the same key, API and account or sandbox. Check which API your endpoint uses.

How do you handle Stripe webhook duplicates and retries?

Verify the signature on the raw body, log each event ID and skip repeats. Return a `2xx` fast and do heavy work on a queue. Stripe retries live-mode delivery for up to three days and does not guarantee order. Some duplicates arrive as separate events, so also compare the object ID and type.

What should a voice agent say when a Stripe payment is declined?

Say the bank declined it and offer another method or a person. Do not read the decline code aloud. Stripe says details behind `lost_card` and `stolen_card` should not be reported to the customer. Map codes to safe phrases in code before the model sees them, and cap re-asks at two.

How do you refund a Stripe payment from a voice agent?

Create a refund against the PaymentIntent or charge, with an optional partial amount. It goes only to the original payment method, and the total cannot exceed the charge. If the PaymentIntent is still `requires_capture`, cancel it instead. Gate the tool with a cap and a logged reason, and promise no date.

How long can you hold a card authorization on Stripe before capture?

For card-not-present payments, Stripe documents 7 days in most cases and 5 days for Visa merchant-initiated transactions, as of November 2026. Extended authorization exists for some online card payments. After the window, funds are released and the payment becomes `canceled`. Read `capture_before` on the charge.

The bottom line

Stripe AI phone payments are correct only when the spoken confirmation, your attempt record and the verified Stripe object all agree. Persist one idempotency key per confirmed amount, trust webhooks over tool results and test the faults before every prompt change.

Related Articles