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.
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:
defcoupon_id="ref-#{amount_cents}-#{currency}"defidreturnif@amount_cents<=0Stripe::Coupon.retrieve(coupon_id)coupon_idrescueStripe::InvalidRequestError# No such coupon yet - create it once under our deterministic id, then reuse it forever.createenddefcreateStripe::Coupon.create(id: coupon_id,amount_off: amount_cents,currency: currency,duration: "once",name: "Friend's discount").idrescueStripe::StripeErrornilend
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:
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.
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
Not a merge. A choice, because Stripe rejects a session that carries both.
Which is obvious once you say what each one means.
discountssays "this purchase is discounted, here is the discount".allow_promotion_codes: truesays "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_offoramount_off, plus acurrencywhen the discount is a fixed amount, plus aduration.durationis the field to slow down on for subscriptions:oncediscounts the first invoice.repeatingdiscounts forduration_in_months.foreverdiscounts every invoice for as long as the subscription lives.foreveron 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_redemptionsandtimes_redeemed, which is how a coupon is capped globally rather than per customer.What a promotion code is
/v1/promotion_codes. It carriescode, the string the customer types, and it points at a coupon. It also carriesactive, an optionalcustomerso the code only works for one person,max_redemptionsof its own, andrestrictions.That indirection is the point of having two objects. One coupon, "20% off", can be reached by
LAUNCH20in a newsletter,PODCAST20on a show, andFRIEND20in 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_codesis 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: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::InvalidRequestErroron the retrieve means "not there yet", which is a normal state and is handled.Stripe::StripeErroron the create means Stripe refused, and the method returnsnilrather 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:
validis an attribute on the coupon, and it is not the same question as "does this id exist". A coupon that has hitmax_redemptions, or passedredeem_by, still retrieves and comes back withvalidfalse. 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.