Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 041 / 100

Coupons and promotion codes: what developers should know

Discounts without breaking billing.

Expona Admin7 min read

If you've ever watched a customer abandon their cart because a discount code didn't work—or worse, apply a discount that shouldn't have been allowed—you know that coupons and promotion codes are far more nuanced than they first appear. On day 41 of our payment engineering journey, we're exploring what developers should know about coupons and promotion codes: the mechanics, restrictions, and critical server-side validation that keep your billing system honest and your margins intact.

Coupons: The foundation

A coupon in Stripe is a reusable discount template. Think of it as a rule set: "give 20% off" or "take $15 off." Coupons don't belong to a customer; they're global objects that live in your Stripe account and can be applied to invoices, subscriptions, or checkout sessions. Once created, a coupon defines the discount amount or percentage, its duration, and metadata like currency or restrictions.

Creating a coupon is straightforward. Here's a 20% off coupon valid for three months:

typescript

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const coupon = await stripe.coupons.create({
  percent_off: 20,
  duration: 'repeating',
  duration_in_months: 3,
  id: 'SUMMER20', // Optional: make it human-readable
});

console.log(coupon.id); // SUMMER20

You can also create a fixed-amount coupon:

typescript

const coupon = await stripe.coupons.create({
  amount_off: 1500, // $15.00 in cents
  currency: 'usd',
  duration: 'once', // Apply only once
});

The key insight: coupons are templates. They exist server-side and should never be created or modified by client-side code. Treat them as configuration, not customer input.

Promotion codes: The customer-facing layer

A promotion code is the public-facing wrapper around a coupon. While a coupon is the discount rule, a promotion code is what customers actually type into your checkout form. One coupon can power multiple promotion codes—useful if you run the same discount under different names across campaigns.

A promotion code links to a coupon, has an optional expiry date, max redemption limits, and can restrict usage by minimum purchase amounts or customer eligibility.

Here's how to create a promotion code tied to our SUMMER20 coupon:

typescript

const promoCode = await stripe.promotionCodes.create({
  coupon: 'SUMMER20',
  code: 'SAVE20',
  active: true,
  max_redemptions: 500,
  expires_at: Math.floor(Date.now() / 1000) + 7776000, // 90 days from now
  metadata: {
    campaign: 'summer_sale',
    channel: 'email',
  },
});

console.log(promoCode.code); // SAVE20

Customers now enter "SAVE20" at checkout. Stripe matches it to the coupon and applies the discount. You can list and retrieve promotion codes server-side to validate them before applying:

typescript

// Validate a promo code server-side
const promoCode = await stripe.promotionCodes.retrieve('promo_XXXXX');

if (!promoCode.active) {
  throw new Error('Promotion code is inactive');
}

if (promoCode.expires_at && promoCode.expires_at * 1000 < Date.now()) {
  throw new Error('Promotion code has expired');
}

if (
  promoCode.max_redemptions &&
  promoCode.times_redeemed >= promoCode.max_redemptions
) {
  throw new Error('Promotion code max redemptions reached');
}

Restrictions: Business rules at the payment layer

Real-world discounts come with conditions. A Black Friday code might only work for new customers. A loyalty code might require a minimum order value. Stripe promotion codes support several restriction patterns.

Minimum purchase amounts

Restrict a promotion code to orders over a certain value:

typescript

const promoCode = await stripe.promotionCodes.create({
  coupon: 'SUMMER20',
  code: 'MIN50',
  restrictions: {
    minimum_amount: 5000, // $50.00 minimum
    minimum_amount_currency: 'usd',
  },
});

If a customer tries to apply this code to a $30 cart, Stripe's checkout or payment intent validation will reject it. You can also check this server-side before showing the discount:

typescript

function canApplyPromoCode(
  cartTotal: number,
  promoCode: Stripe.PromotionCode
): boolean {
  if (promoCode.restrictions?.minimum_amount) {
    return cartTotal >= promoCode.restrictions.minimum_amount;
  }
  return true;
}

First-time customer restrictions

Stripe doesn't have a built-in "first-time only" restriction on promotion codes. Instead, you enforce this in your application logic:

typescript

async function applyPromoCodeToCheckout(
  customerId: string,
  promoCodeId: string,
  isFirstPurchase: boolean
): Promise {
  const promoCode = await stripe.promotionCodes.retrieve(promoCodeId);

  // Check if promo code is flagged as first-time only
  if (
    promoCode.metadata?.first_time_only === 'true' &&
    !isFirstPurchase
  ) {
    throw new Error('This code is only valid for first-time customers');
  }

  // Proceed with applying the code
}

Expiry and max redemptions

Both are built into Stripe promotion codes. Set expires_at (Unix timestamp) and max_redemptions when creating the code. Stripe tracks times_redeemed automatically. Once either limit is hit, the code becomes unusable.

Validate server-side: Never trust the discount from the client

This is the golden rule. Your frontend sends a promotion code string to your backend. Your backend must validate it independently before charging the customer. Never apply a discount based solely on client-side state.

The pattern: validate before payment

When a customer submits a discount code, validate it immediately:

typescript

async function validateAndApplyPromoCode(
  promoCodeString: string,
  cartTotal: number
): Promise {
  // Fetch all promotion codes and find a match
  const promoCodeList = await stripe.promotionCodes.list({
    code: promoCodeString,
    active: true,
    limit: 1,
  });

  if (promoCodeList.data.length === 0) {
    throw new Error('Promotion code not found or inactive');
  }

  const promoCode = promoCodeList.data[0];

  // Check expiry
  if (promoCode.expires_at && promoCode.expires_at * 1000 < Date.now()) {
    throw new Error('Promotion code expired');
  }

  // Check max redemptions
  if (
    promoCode.max_redemptions &&
    promoCode.times_redeemed >= promoCode.max_redemptions
  ) {
    throw new Error('Promotion code no longer available');
  }

  // Check minimum amount
  if (
    promoCode.restrictions?.minimum_amount &&
    cartTotal < promoCode.restrictions.minimum_amount
  ) {
    throw new Error(
      `Minimum purchase of $${(
        promoCode.restrictions.minimum_amount / 100
      ).toFixed(2)} required`
    );
  }

  return promoCode;
}

Applying the validated code to a checkout session

Once validated, pass the promotion code ID to your Stripe Checkout Session or Payment Intent:

typescript

const validatedPromo = await validateAndApplyPromoCode('SAVE20', 10000);

const session = await stripe.checkout.sessions.create({
  payment_method_types: ['card'],
  line_items: [
    {
      price: 'price_XXXXX',
      quantity: 1,
    },
  ],
  mode: 'payment',
  success_url: 'https://example.com/success',
  cancel_url: 'https://example.com/cancel',
  discounts: [
    {
      promotion_code: validatedPromo.id,
    },
  ],
});

Stripe now applies the discount within the checkout session. The customer sees it before paying, and the discount is locked into the invoice or payment record.

For subscriptions

Apply a coupon (not a promotion code) directly to a subscription creation or update:

typescript

const subscription = await stripe.subscriptions.create({
  customer: 'cus_XXXXX',
  items: [
    {
      price: 'price_XXXXX',
    },
  ],
  coupon: 'SUMMER20',
});

Validate the coupon the same way you validate promotion codes server-side before attaching it.

Common mistakes

  • Trusting client-side coupon validation: A determined user can modify their browser console or network requests to claim any discount. Always re-validate on the server.
  • Forgetting to check expiry and max redemptions: Even if Stripe rejects an expired code during payment, you should fail fast on your side with a clear error message. Don't let validation happen at the last moment.
  • Applying discounts to the wrong scope: A coupon created with `duration: 'once'` applies to a single invoice. Using it on a subscription will only discount the first month. Clarify scope in your coupon naming or metadata.
  • Not handling promotion code deletion: If you delete or deactivate a promotion code, ensure your frontend stops offering it. If a customer has the old code memorized, your server validation will correctly reject it.
  • Mixing coupon IDs with promotion code IDs: They're different objects. When applying discounts via the API, ensure you're using the right object type for the context (coupon for subscriptions, promotion code for checkout sessions).

Key takeaways

  • Coupons are reusable discount rules (percent or fixed amount) defined server-side; promotion codes are customer-facing strings that reference coupons.
  • Restrictions like minimum amounts, expiry dates, and max redemptions belong on the promotion code object or in your application logic.
  • Always validate promotion codes and coupons server-side before charging. Never trust client-side discount claims.
  • Use Stripe's checkout session or subscription creation APIs to attach validated discounts; the payment processor locks them in, making tampering harder.
  • Clear error messages when a code fails (expired, max reached, minimum not met) improve customer experience and reduce support tickets.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles