Guide

Stripe invoice has no payment_intent anymore: the basil API change and the fix

Updated 2026-10-05 · RevGuard Labs

If a workflow that read invoice.payment_intent started getting undefined, nothing is wrong with your code. Stripe removed the field in API version 2025-03-31.basil, and anything that depended on it now fails quietly.

What changed

On the older API versions the invoice object carried payment_intent, charge and paid. From 2025-03-31.basil they are gone. An invoice can now have several payment attempts, so Stripe moved them onto their own object, the Invoice Payment. The invoice only says whether it is paid, through status.

The part that catches people is how you end up on the new version without choosing it. A new Stripe account starts on the latest API version. A test account you created last month, or a restricted key from a new project, behaves differently from the live account you wrote the code against. The same workflow works in one place and returns nothing in another.

The symptom

You receive invoice.payment_failed, read event.data.object.payment_intent, and get nothing. The next step builds a URL like /v1/payment_intents/undefined, Stripe answers 404, and the case either errors out or, worse, is filed as an unknown decline. A failed card gets no retry and no email, and nothing in the logs says why.

The fix: ask for the invoice's payments

Fetch the payment records for the invoice and expand the payment intent in the same call:

GET /v1/invoice_payments?invoice=in_123&limit=1&expand[]=data.payment.payment_intent

The newest attempt is first, so data[0].payment.payment_intent is the intent you used to read directly. From there it is the same as before: last_payment_error.decline_code holds the reason, last_payment_error.code the broader error, and advice_code sometimes says outright that the bank wants you to stop.

Two practical notes. Expanding saves a second request, but check for an unexpanded string before you read a field from it, because a restricted key that cannot read payment intents will give you the id and nothing else. And keep the old path as a fallback: if the event still carries a payment_intent (an older webhook endpoint pinned to an older version does), use it directly.

Other things that move with it

  • invoice.paid the boolean is gone. Check status === "paid". If your re-check before a retry or an email looks at the old field, it will always think the invoice is unpaid and you will email people who already paid.
  • Webhook endpoints keep the version they were created with. Updating your account's default version does not change what an existing endpoint sends. You can have the old shape arriving at one URL and the new shape at another, so handle both.
  • The legacy card source. For cards created through the old Sources API the error sits under last_payment_error.source rather than payment_method. Read the brand from whichever is present.
  • 3D Secure is its own case. When the bank wants the customer present, the payment intent status is requires_action and there is no decline to classify. Retrying does nothing. The customer has to complete the authentication on the hosted invoice page, so send them there instead of running the retry schedule.

How to tell which version you are on

In the Stripe dashboard, Developers, then the API version shown for your account and for each webhook endpoint. For a quick check, open one failed invoice from your own event log: if there is no payment_intent key on it, you are on basil or later. A test failure is easy to produce with the card 4000 0000 0000 0341, which attaches fine and then fails the charge.

What this does to the free template and the paid workflow

The free classifier on GitHub and the Stripe recovery workflow both had this bug until version 1.2.0, because both were built against the older shape. Both now fetch through invoice_payments and fall back to the direct payment intent when it is present. If you downloaded either before 5 October 2026, grab the current version. The same step-by-step logic, with the new request, is in the n8n workflow guide and the decline code guide.

Read next