LaunchKit

Stripe coupons and promotion codes are two objects, and Checkout takes one or the other

September 14, 2026

Stripe's discount API has two objects with one job between them, and the documentation names them so similarly that most integrations only ever meet one. The whole distinction fits in a sentence: a coupon is the discount, a promotion code is a string that points at a coupon.

Everything else follows, including the reason a Rails integration ends up with a ternary in it.

The billing hub covers the money arriving. Rails Stripe Checkout covers the session that collects it. This is one argument of that session.

The line

def discount_options
  coupon.present? ? { discounts: [ { coupon: coupon } ] } : { allow_promotion_codes: true }
end

Not a merge. A choice, because Stripe rejects a session that carries both.

Which is obvious once you say what each one means. discounts says "this purchase is discounted, here is the discount". allow_promotion_codes: true says "render a box and let the customer name a discount". A session that did both would be offering a customer the chance to stack a second discount on top of one you already granted, and Stripe declines to have that argument with you.

So the shape of the decision in a Rails application is: is this a discount we are applying, or a discount they are claiming? A referral link where the discount is already earned takes the first branch. An ordinary pricing page takes the second.

What a coupon is

/v1/coupons. A rule: percent_off or amount_off, plus a currency when the discount is a fixed amount, plus a duration.

duration is the field to slow down on for subscriptions:

  • once discounts the first invoice.
  • repeating discounts for duration_in_months.
  • forever discounts every invoice for as long as the subscription lives.

forever on a monthly plan is not a promotion, it is a price. Customers who take it keep it, and taking it back later means migrating them onto a different price and explaining why.

There is also max_redemptions and times_redeemed, which is how a coupon is capped globally rather than per customer.

What a promotion code is

/v1/promotion_codes. It carries code, the string the customer types, and it points at a coupon. It also carries active, an optional customer so the code only works for one person, max_redemptions of its own, and restrictions.

That indirection is the point of having two objects. One coupon, "20% off", can be reached by LAUNCH20 in a newsletter, PODCAST20 on a show, and FRIEND20 in a referral email, each with its own expiry and its own redemption count, all reporting into the same discount. Deleting a leaked code does not touch the other two.

If you only ever apply discounts yourself, you never need a promotion code at all. Coupons are enough, and allow_promotion_codes is the feature you are not using.

Creating a coupon exactly once

Stripe lets you choose a coupon's id, which is more useful than it first appears. A deterministic id turns "create this coupon if it does not exist" into a lookup:

def coupon_id = "ref-#{amount_cents}-#{currency}"

def id
  return if @amount_cents <= 0

  Stripe::Coupon.retrieve(coupon_id)
  coupon_id
rescue Stripe::InvalidRequestError
  # No such coupon yet - create it once under our deterministic id, then reuse it forever.
  create
end

def create
  Stripe::Coupon.create(
    id: coupon_id, amount_off: amount_cents, currency: currency,
    duration: "once", name: "Friend's discount"
  ).id
rescue Stripe::StripeError
  nil
end

The rescue is doing the work, and it is worth being explicit about why it is written this way round. There is no upsert on this resource. Creating an id that already exists is an error, and retrieving an id that does not exist is also an error, so one of the two calls is going to raise no matter which order you try them in. Retrieve-then-create is the order where the raise happens once, on the first referral ever, and never again.

Without a deterministic id you get a new coupon object per referred checkout. They work, and your Stripe dashboard fills with thousands of identical five-dollar coupons that nobody can report on.

Note the two different rescues. Stripe::InvalidRequestError on the retrieve means "not there yet", which is a normal state and is handled. Stripe::StripeError on the create means Stripe refused, and the method returns nil rather than raising, because a discount that cannot be created should cost the customer their discount and not their checkout.

Validating one before you quote a price

A coupon id typed into an admin form is a string somebody believed. If a landing page quotes a discounted price from it, the page and the checkout have to agree, and the only way to know is to ask Stripe:

coupon = Stripe::Coupon.retrieve(@coupon_id)
return Result.failure(...) unless coupon.valid

valid is an attribute on the coupon, and it is not the same question as "does this id exist". A coupon that has hit max_redemptions, or passed redeem_by, still retrieves and comes back with valid false. Checking only for a successful retrieve gets you a page advertising a discount that Checkout will refuse, which is the worst version of this bug because it surfaces at the moment of payment.

The service that does this never raises: every Stripe error becomes a failed result with a message for the admin. An invalid coupon id is a data entry mistake, and a data entry mistake should not be a 500.

What this page does not cover

Tax behaviour on discounted amounts, which is its own subject and interacts with Stripe Tax. Proration when a discount is applied mid-cycle, which belongs with plan changes. And the Billing Portal's own promotion code settings, which live in the portal configuration rather than in your code and can be changed without a deploy.

The discount reaches your database through the same webhook as everything else, so Stripe webhooks in Rails is where a discounted subscription becomes a row that says what the customer is actually paying.

More on Stripe billing in Rails

← All Stripe billing in Rails articles