log in
consulting hosting industries the daily tools about contact

Stripe Billing Idempotency Keys: What You're Probably Skipping

Most Laravel devs add idempotency keys to charge calls and call it done. That's not enough, and I've got the production incident to prove it.

Most Laravel devs add idempotency keys to their charge or payment intent calls and consider the job done. I did too, for longer than I'd like to admit. Then a subscription billing job retried after a network timeout and a Seattle-area e-commerce client ended up with two active subscriptions, two upcoming invoices, and a very confused customer. That's what motivated me to actually audit which Stripe calls need keys, which ones are safe to skip, and why the distinction matters more than you think.

What Idempotency Actually Buys You

Stripe's idempotency key system is a server-side deduplication cache. You send a unique key with a request, and if Stripe sees that same key again within 24 hours, it returns the original response instead of executing the operation a second time. No double charge. No duplicate subscription. No extra invoice item.

The key insight is that this protection is request-scoped, not resource-scoped. Stripe doesn't automatically know you "already meant to create this subscription." It only knows you already made this exact request if you tell it so with a key. Without one, every retry is a fresh operation.

In a Laravel app with queued jobs, this matters constantly. A job can fail mid-flight, get requeued, and fire again. Your HTTP client can time out waiting on Stripe's response while Stripe already processed the request successfully. Redis can hiccup and your job visibility timeout can expire. All of these scenarios result in a retry, and without idempotency keys on the right calls, you get duplicate side effects.

The Calls That Actually Need Keys

Here's my working list, organized by how much damage a duplicate causes:

High damage - always use a key:

  • PaymentIntent::create - obvious, double charge
  • Subscription::create - this is the one that bit me
  • Invoice::pay - manually paying a draft invoice twice will attempt two charges
  • InvoiceItem::create - duplicate line items on the next invoice
  • SetupIntent::create - less catastrophic but creates noise and confusing audit trails
  • Customer::create - you can end up with two customer records for one user, and merging them later is miserable

Medium damage - use a key when in a queue context:

  • Subscription::update - idempotent by nature if the data is the same, but Stripe still processes the request, and some updates trigger proration calculations you don't want doubled
  • SubscriptionItem::update - same story
  • PaymentMethod::attach - attaching the same card twice throws an error, but in a retry loop you might not handle that error gracefully

Safe to skip in most cases:

  • Customer::retrieve, Subscription::retrieve, any GET - read operations don't mutate state
  • PaymentMethod::retrieve
  • Price::retrieve, Product::retrieve
  • Webhook event retrieval

The pattern I follow: if the Stripe call creates or modifies a billable resource, and it lives in a queued job, it gets a key. No exceptions.

Generating Keys That Actually Work

A good idempotency key needs to be deterministic for a given logical operation and unique across different operations. Random UUIDs are fine for one-shot HTTP requests from a controller, but they're wrong for queued jobs - every retry generates a new UUID and you've defeated the whole system.

Here's the pattern I use in Laravel:

<?php

namespace App\Services;

use Stripe\StripeClient;

class StripeSubscriptionService
{
    public function __construct(private StripeClient $stripe) {}

    public function createSubscription(int $userId, string $priceId, string $paymentMethodId): \Stripe\Subscription
    {
        // Key is deterministic: same user + same price always produces the same key.
        // If the job retries, Stripe returns the original response.
        $idempotencyKey = hash('sha256', "create-subscription:{$userId}:{$priceId}");

        return $this->stripe->subscriptions->create(
            [
                'customer'         => $this->getStripeCustomerId($userId),
                'items'            => [['price' => $priceId]],
                'default_payment_method' => $paymentMethodId,
                'expand'           => ['latest_invoice.payment_intent'],
            ],
            [
                'idempotency_key' => $idempotencyKey,
            ]
        );
    }

    public function addInvoiceItem(int $userId, string $stripeCustomerId, int $amountCents, string $description, string $jobRunId): \Stripe\InvoiceItem
    {
        // For invoice items, include a job run ID or some operation-specific token
        // so that re-running the job for a *different* billing cycle creates a fresh item.
        $idempotencyKey = hash('sha256', "invoice-item:{$stripeCustomerId}:{$jobRunId}");

        return $this->stripe->invoiceItems->create(
            [
                'customer'    => $stripeCustomerId,
                'amount'      => $amountCents,
                'currency'    => 'usd',
                'description' => $description,
            ],
            [
                'idempotency_key' => $idempotencyKey,
            ]
        );
    }

    private function getStripeCustomerId(int $userId): string
    {
        // Your lookup logic here
        return User::findOrFail($userId)->stripe_customer_id;
    }
}

A few things to notice. The key for createSubscription encodes the user and price but not a timestamp. That's intentional - if the job retries five minutes later because of a Redis blip, I want the same key. But it also means if that user legitimately cancels and re-subscribes to the same plan, I'd get the cached response back, which is wrong.

For most of my use cases, a subscription re-create is rare enough that I handle it explicitly - cancel first, then generate a new key that includes a version counter or a timestamp rounded to the billing period. Your logic will vary, but the point is: think about the key's natural expiry and what "same operation" actually means in your domain.

The Gotchas That Will Burn You

Mixing the PHP library's idempotency key location. The key goes in the options array, not the params array. I've seen this mistake in code review more than once:

// Wrong - idempotency_key in params does nothing useful
$this->stripe->subscriptions->create([
    'customer' => $customerId,
    'items'    => [['price' => $priceId]],
    'idempotency_key' => $key, // this just gets sent as an unknown param
]);

// Right - second argument is the options array
$this->stripe->subscriptions->create(
    ['customer' => $customerId, 'items' => [['price' => $priceId]]],
    ['idempotency_key' => $key]
);

Stripe won't error on the wrong version. It'll just silently ignore the key and process every request fresh. Fun to debug.

Cashier hides the options array. If you're using Laravel Cashier, most of the fluent methods accept an $options array as a trailing parameter. Check the source - it passes your options straight through to the underlying Stripe call. But some Cashier methods don't expose the options array at all, which means you can't set an idempotency key without bypassing Cashier and calling the Stripe SDK directly. For complex billing logic I usually end up on the SDK anyway, but it's worth knowing before you assume Cashier has you covered.

Keys are endpoint-specific. A key used for subscriptions->create won't conflict with the same string used for invoiceItems->create. But you should still namespace them properly for your own sanity when reading logs.

The 24-hour window. Stripe caches idempotent responses for 24 hours. If a legitimate retry happens 25 hours after the original request, you're getting a fresh operation. For most queued jobs that's fine - retries happen in minutes. But I've seen billing recovery jobs that sleep for hours before retrying, and if you're in that territory you need to think about it.

Stripe will error if you reuse a key with different params. If the key matches but the request body changed, Stripe returns a 400 with idempotency_key in the error. This is actually useful - it means you've got a bug in your key generation logic if two logically different operations are producing the same key.

When I'd Reach for This vs. When I Wouldn't Bother

If the Stripe call is happening synchronously in a controller - user clicks "Subscribe" and you immediately call Stripe - a random UUID or Str::uuid() is fine. The user isn't going to double-submit in a way that creates two in-flight requests with different keys. Use a key anyway for safety, but the risk is lower.

If the call is in a queued job, a scheduled command, or any background process - use a deterministic key, full stop. No argument.

If you're doing bulk billing operations, like adding invoice items for metered usage across hundreds of accounts in a loop, treat each iteration as a queued job from a key-safety standpoint. The loop will fail partway through eventually. You need to be able to re-run it without double-billing anyone.

I'd also reach for deterministic keys any time I'm integrating with an external system that triggers Stripe calls via webhook. I've built a couple of integrations where a LIMS system fires a webhook on a billable event, and our app processes it into a Stripe invoice item. Webhook delivery is at-least-once by nature. Without a key derived from the source event ID, you'll eventually add that line item twice.

// Keyed on the upstream event ID from the external system
$idempotencyKey = hash('sha256', "lims-invoice-item:{$limsEventId}");

Clean, simple, and it survives duplicate webhook deliveries without any additional deduplication logic on my side.

Closing

Idempotency keys are not a nice-to-have in production billing systems. They are the difference between a self-healing retry and a customer service incident. Audit your Stripe calls, find the ones living inside queued jobs, and add deterministic keys to every one that mutates state. It takes an afternoon the first time and about thirty seconds per call after that.

Related

Need help shipping something like this? Get in touch.