---
title: "Stripe — Purchases for Laravel | Roundly"
description: "Verify Stripe webhook signatures natively, map payment, subscription, invoice and dispute events, and read REST objects."
url: https://roundly-consulting.com/open-source/docs/purchases-for-laravel/stripe
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Purchases for Laravel](https://roundly-consulting.com/open-source/docs/purchases-for-laravel.md)

# Stripe

The Stripe provider (id stripe) verifies webhook signatures natively — HMAC-SHA256 over t.payload from the Stripe-Signature header, a constant-time comparison and a configurable timestamp tolerance — and reads REST objects with the pinned API version:

```php
use RoundlyConsulting\Purchases\Providers\Stripe\Stripe;

$stripe = app(Stripe::class);

$event = $stripe->notification($request);   // verifies Stripe-Signature, returns StripeEvent
$event->type;                               // EventType, e.g. EventType::InvoicePaid
$event->object;                             // array — the event's data.object

$stripe->paymentIntent('pi_123');           // PaymentIntent
$stripe->subscription('sub_123');           // Subscription
$stripe->session('cs_123');                 // CheckoutSession
$stripe->invoice('in_123');                 // Invoice
```

## Supported events

| Stripe event | Result | Status | Amount |
| --- | --- | --- | --- |
| `payment_intent.succeeded` | Purchase (audited only when it pays an invoice) | The intent’s own status | `amount` |
| `payment_intent.payment_failed` | Purchase (audited only when it pays an invoice) | `Failed` | `amount` |
| `checkout.session.completed` | Purchase keyed on its PaymentIntent (payment mode; subscription and setup modes are audited only) | Completed when payment\_status is paid or no\_payment\_required, else Pending | `amount_total` |
| `invoice.paid` | Purchase for a one-off invoice, keyed on the invoice with the PaymentIntent that paid it as transaction\_id (subscription invoices are audited only) | `Completed` | `amount_paid` |
| `invoice.payment_failed` | Purchase for a one-off invoice (subscription invoices are audited only) | `Failed` | `amount_due` |
| `customer.subscription.created / .updated / .deleted` | Subscription | Mapped from the subscription status | — |
| `charge.refunded` | Refund | Refunded, or Completed for a partial refund | `amount_refunded` |
| `charge.dispute.created` | Refund (chargeback) | `Refunded` | `amount` |
| `charge.dispute.closed` | Won: the same chargeback row is reversed and a recorded purchase reinstated; lost: the chargeback stands | Completed when won | `amount` |
| charge.dispute.updated, inquiries, unmapped events | Informational — audited, never applied | — | — |

## Subscription statuses

| Stripe subscription status | Status |
| --- | --- |
| trialing, active | `Completed` |
| past\_due | `InGracePeriod` |
| incomplete | `Pending` |
| paused, unpaid | `OnHold` |
| canceled, incomplete\_expired | `Canceled` |

Subscription billing is not recorded as purchases: a subscription- or setup-mode Checkout and a subscription invoice are audited only, and the subscription’s own customer.subscription.\* events keep its state. Those events carry the current period start and end and the trial end; they carry no price. A PaymentIntent that pays any invoice is audited only too — a renewal belongs to its subscription, and a one-off invoice is already recorded once, from invoice.paid.

## Which invoice a PaymentIntent pays

Since API version 2025-03-31 (the pinned 2026-05-27.dahlia included) a PaymentIntent no longer says which invoice it pays, so for a payment\_intent.\* event without an invoice field the package asks Stripe’s Invoice Payments API (GET /v1/invoice\_payments). That needs PURCHASES\_STRIPE\_SECRET: without it such an event is refused (VerificationException, 400 on the route, so Stripe retries) rather than guessed at — a guess would record every renewal as a new one-off purchase.

## One-off invoices and their refunds

A one-off invoice recorded from invoice.paid is keyed on the invoice (in\_…), with the PaymentIntent that paid it as its transaction\_id — so its charge.refunded and charge.dispute.\* events, which name the PaymentIntent, link to it and flip it to Refunded. The PaymentIntent comes from the invoice’s legacy payment\_intent field when it has one (null — paid out of band — keeps the invoice id), else from GET /v1/invoice\_payments?invoice=…&status=paid (API 2025-03-31 and later, the is\_default payment winning), which needs PURCHASES\_STRIPE\_SECRET as well. Both Invoice Payments lookups run while the webhook is verified, also with the queue on: a Stripe error answer or an unreachable Stripe fails the request with a 500, so Stripe redelivers the event. Their ids come from the signed event, never from a client.

## Disputes

A dispute is a chargeback only while the funds are gone: an inquiry (warning\_\*) and charge.dispute.updated change nothing, and a lost dispute stays the single chargeback recorded at charge.dispute.created (keyed on the dispute id). A dispute closed as won updates that same row and reinstates the refunded purchase it was linked to (PurchaseCompleted) — its own payment data is kept, and only a purchase the package recorded is reinstated: a won dispute of a payment that never became a Purchase (a subscription renewal’s) creates none and fires nothing.

## Client callbacks

Stripe::callback() retrieves a Checkout Session from a session\_id input — which wins when both are sent — or a PaymentIntent from a payment\_intent input; handy on a success-redirect page. The webhook route takes signed events only, so call it from your own route:

```php
// GET /checkout/success?session_id=cs_123
$session = app(Stripe::class)->callback($request);   // CheckoutSession (PaymentIntent for ?payment_intent=pi_123)

$session->paymentStatus;   // 'paid'
$session->amountTotal;     // ?Money
$session->subscription;    // ?string — the subscription id, for subscription checkouts
```

It refuses a malformed id before Stripe is called and path-encodes the one it sends. An id Stripe rejects with a 4xx is a VerificationException, while a refused key, a rate limit or an outage stays a RequestException — see Client callbacks.

## Special currencies

Amounts are read in Stripe’s smallest currency unit and re-scaled where Stripe differs from ISO 4217 — ISK and UGX are sent with two decimals, MGA with none (StripeAmount::SCALE\_EXCEPTIONS). A float or fractional-string amount is refused, never truncated.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/purchases-for-laravel.md)
