Skip to content

How to add a tip to an online Stripe checkout

Stripe Checkout and Payment Links have no tip setting. Stripe's tipping features run on Terminal card readers, and the online option, "customers choose what to pay", replaces your price instead of adding to it. To collect a tip on top of a sale, your code adds it: as a line item, as an optional item, or as a change to the payment amount.
Verified against official documentationReviewed Send a correction (opens in a new tab)

The closest thing Stripe has to an online tip is a price the buyer fills in. Payment Links call it "customers choose what to pay"; in the API it is custom_unit_amount:

cURL
curl https://api.stripe.com/v1/prices \
  -u "sk_test_...:" \
  -d currency=usd \
  -d "custom_unit_amount[enabled]=true" \
  -d "custom_unit_amount[preset]=500" \
  -d product=prod_...

A Checkout Session or Payment Link built on that price takes nothing else. Stripe lists the limits: no other line items, a quantity of 1, no promotion codes or discounts, and no recurring payments or optional items. That works as a tip jar, where the tip is the whole payment. It can't put a tip on top of a $120.00 service in the same checkout. If you want that as a setting rather than code, it is one block on a Flint payment link.

Where to start

What you haveYour path
Checkout Sessions on Stripe's hosted pageAsk first, send the tip as a line item
A Payment Link with no server codeFixed tip add-ons with optional items
Checkout Sessions with Checkout elements on your pageUpdate the tip line while the buyer pays
The Payment Element on PaymentIntentsUpdate the PaymentIntent amount
The buyer wants to tip after payingA tip jar link on your confirmation page
A card reader, in personTerminal on-reader tipping
Percent buttons and a custom amount on a hosted payment pageTipping on Flint

Where Stripe supports tipping

Stripe documents two tipping methods, and both belong to Terminal:

  • On-reader tipping. The card reader shows three suggestions before the customer presents a card: percentages, fixed amounts, or smart tips that switch between the two at a threshold you set. The customer can also enter a custom tip or leave none. It runs on the Stripe Reader S700 and S710, the BBPOS WisePOS E, and the BBPOS WisePad 3, and the tip comes back on the PaymentIntent in amount_details.tip.
  • On-receipt tipping. The tip is written on a paper receipt and added when you capture the payment. It needs manual capture and is available in the US only.

Neither reaches a browser. The Stripe-hosted payment page offers quantity controls, optional items, promotion codes, and custom fields, and no tip prompt, so each online path below builds one out of those parts or out of your own UI.

Checkout Sessions: ask first, send the tip as a line item

On Stripe's hosted page, the tip has to be decided before the session exists. Your cart page shows the tip buttons, works out each percentage from your subtotal, and posts the buyer's choice to your server. The server validates the amount and appends one more line item when it creates the session:

JavaScript
// The buyer chose a tip on your cart page. Recompute it on the server
// from your own subtotal; never trust an amount from the browser.
const tipAmount = clampTip(req.body.tip_amount, cartSubtotal);

const session = await stripe.checkout.sessions.create({
  mode: "payment",
  line_items: [
    ...cartLineItems,
    ...(tipAmount > 0
      ? [
          {
            quantity: 1,
            price_data: {
              currency: "usd",
              unit_amount: tipAmount,
              // A Product named "Tip" with tax_code txcd_00000000
              product: process.env.STRIPE_TIP_PRODUCT_ID,
            },
          },
        ]
      : []),
  ],
  payment_intent_data: { metadata: { tip_amount: String(tipAmount) } },
  success_url: "https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}",
  cancel_url: "https://example.com/cart/tip",
});

The buyer sees "Tip" as a product row on Stripe's page. Use a real Product for it rather than inline product_data: a Product can carry the Nontaxable tax code, and coupons can be scoped away from it. Both matter in the traps below.

Payment Links: fixed tip add-ons with optional items

Optional items are Stripe's upsell feature: up to 10 extra products the buyer can add on the payment page, on Checkout Sessions and Payment Links alike. A $1.00 "Tip" price with an adjustable quantity works as a tip stepper with no server code:

Shell
# price_tip_100 is a one-time $1.00 price on a Product named "Tip"
curl https://api.stripe.com/v1/payment_links \
  -u "sk_test_...:" \
  -d "line_items[0][price]=price_..." \
  -d "line_items[0][quantity]=1" \
  -d "optional_items[0][price]=price_tip_100" \
  -d "optional_items[0][quantity]=5" \
  -d "optional_items[0][adjustable_quantity][enabled]=true" \
  -d "optional_items[0][adjustable_quantity][minimum]=0" \
  -d "optional_items[0][adjustable_quantity][maximum]=50" \
  -d "payment_intent_data[metadata][has_tip_item]=true"

The buyer adds the item and sets how many dollars to tip. There are no percentages and no typed amount, the control reads as a product add-on rather than a tip prompt, and the buyer can always remove it. Optional items can't be combined with pay-what-you-want prices, so a free-form tip field is still out of reach on the link.

Your own page: a tip selector that changes the total

When the payment form sits on your page, the tip selector can sit next to it and the buyer can change the tip until they pay. Stripe has two ways to do that, depending on which API the form is built on.

Checkout elements: update the session's line items

A Checkout Session created with ui_mode=elements accepts line item updates during checkout. Each update resends the whole line_items array: existing lines are kept by id, and a line you leave out is removed. Your endpoint swaps the tip line:

JavaScript
// POST /checkout/tip: the buyer picked a tip in your selector
app.post("/checkout/tip", async (req, res) => {
  const { checkout_session_id, tip_amount } = req.body;
  const session = await stripe.checkout.sessions.retrieve(checkout_session_id);
  if (session.status !== "open") {
    return res.status(400).json({ message: "This checkout has closed." });
  }

  const subtotal = await cartSubtotal(session.client_reference_id); // your database
  const tipAmount = clampTip(tip_amount, subtotal);

  // Resend every line: keep the cart lines by id, replace the tip line
  const { data: lines } = await stripe.checkout.sessions.listLineItems(
    checkout_session_id,
    { limit: 100 },
  );
  const cartLines = lines
    .filter((line) => line.price.product !== TIP_PRODUCT_ID)
    .map((line) => ({ id: line.id }));

  await stripe.checkout.sessions.update(checkout_session_id, {
    line_items: [
      ...cartLines,
      ...(tipAmount > 0
        ? [{
            quantity: 1,
            price_data: { currency: "usd", unit_amount: tipAmount, product: TIP_PRODUCT_ID },
          }]
        : []),
    ],
    metadata: { tip_amount: String(tipAmount) },
  });
  res.json({ ok: true });
});

In the browser, run the request inside runServerUpdate so the form picks up the new total:

JavaScript
const checkout = stripe.initCheckoutElementsSdk({ clientSecret });
const loaded = await checkout.loadActions();
if (loaded.type !== "success") throw new Error("Checkout failed to load");
const { actions } = loaded;

async function chooseTip(tipAmount) {
  try {
    // Refreshes the session and its total; 20 second timeout
    await actions.runServerUpdate(async () => {
      const response = await fetch("/checkout/tip", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          checkout_session_id: actions.getSession().id,
          tip_amount: tipAmount,
        }),
      });
      if (!response.ok) throw new Error((await response.json()).message);
    });
  } catch (error) {
    showTipError(error.message);
  }
}

The tip is still a product line, so the coupon and tax traps below apply to it as they do on the hosted page.

Payment Element: update the PaymentIntent amount

With the Payment Element on PaymentIntents, there are no line items. When the buyer picks a tip, update the amount on your server and record the tip where you can find it later:

JavaScript
// POST /cart/tip: the buyer picked a tip in your own selector
const subtotal = await cartSubtotal(cartId); // from your database
const tipAmount = clampTip(req.body.tip_amount, subtotal);

await stripe.paymentIntents.update(paymentIntentId, {
  amount: subtotal + tipAmount,
  metadata: { tip_amount: String(tipAmount) },
});

Then refresh the Payment Element so the total it shows matches:

JavaScript
await fetch("/cart/tip", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ tip_amount: tipAmount }),
});

// Pull the new amount into the Payment Element and the wallet sheets
await elements.fetchUpdates();

Stripe sees one amount. The split between sale and tip exists only in the metadata you wrote and in your own database.

A tip can't be added to an online payment that has already succeeded, so a later tip is a second payment. Create a Payment Link on a pay-what-you-want price for tips, and send buyers from the purchase to a page of yours that links to it:

cURL
curl https://api.stripe.com/v1/payment_links \
  -u "sk_test_...:" \
  -d "line_items[0][price]=price_tip_custom_amount" \
  -d "line_items[0][quantity]=1" \
  -d "after_completion[type]=redirect" \
  --data-urlencode "after_completion[redirect][url]=https://example.com/thanks"

On that page, add prefilled_amount to suggest an amount and client_reference_id to name the order the tip is for:

Text
https://buy.stripe.com/...?prefilled_amount=500&client_reference_id=order_1042

The buyer enters payment details a second time. The tip arrives as its own payment, and the reference comes back in the checkout.session.completed event for your code to match to the order.

The four places the workarounds go wrong

  1. A percent-off coupon discounts the tip#

    A buyer tips $10.00, enters a 20% promotion code, and the tip line settles at $8.00.

    A coupon without applies_to discounts the entire purchase subtotal, and a tip sent as a line item is part of that subtotal. Checkout has no kind of line that a discount skips.

    Confirm it

    List the line items of a completed session. A tip line with a nonzero amount_discount was discounted.

    Fix

    Create the tip as a real Product, pass its ID in price_data[product], and limit every coupon to the products you sell with applies_to[products]. Each new coupon needs the same list, including the ones a teammate creates in the Dashboard.

  2. Stripe Tax taxes the tip line#

    With automatic tax on, the tip line carries tax like any other product.

    A line item with no tax_code gets your account's preset product tax code, so Stripe Tax treats the tip as whatever you usually sell.

    Confirm it

    Create a test session with automatic_tax[enabled]=true and read amount_tax on the tip line.

    Fix

    Set tax_code to txcd_00000000 (Nontaxable) on the tip Product, or in product_data when you build the line inline. Confirm with your tax adviser that a voluntary gratuity is not taxable where you sell.

  3. The buyer can't change the tip on Stripe's hosted page#

    The buyer reaches the payment page, sees the total, and wants a different tip. The only way is back.

    The hosted page has quantity controls and optional items, and no input for an amount outside pay-what-you-want, which can't share the session with your products. Line item updates during checkout need a custom UI mode.

    Confirm it

    Open a session URL. The tip shows as a product row with a fixed price and nothing to edit.

    Fix

    Point cancel_url at your tip step so going back keeps the cart. Create a new session for the new tip and expire the old one so it can't be paid.

  4. Refunds and reports can't see the tip#

    You refund the service and mean to leave the tip, or you owe each staff member their tips, and Stripe shows one charge amount.

    A Stripe refund is an amount against the payment. The tip is a product row on a Checkout Session, or whatever you wrote to metadata on a PaymentIntent. Neither is a tip total that a Stripe report adds up.

    Confirm it

    Try to answer "how much did we collect in tips last week" from the Dashboard alone.

    Fix

    Write tip_amount to the PaymentIntent metadata on every path (payment_intent_data[metadata] for Checkout Sessions and Payment Links), compute refund amounts from it, and keep the tip ledger in your own database.

On Flint, the tip is a checkout setting

Flint's hosted checkout has a tip prompt. Add a tip block when you create a payment link or a checkout session, next to the items being sold:

cURL
curl -X POST https://api.withflintpay.com/v1/payment-links \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: payment-link-garden-cleanup-001" \
  -d '{
    "name": "Garden cleanup visit",
    "line_items": [{
      "type": "custom_fixed",
      "name": "Garden cleanup, 2 hours",
      "quantity": 1,
      "unit_price_money": {"amount": 12000, "currency": "USD"}
    }],
    "tip": {
      "enabled": true,
      "tip_percent_options": [15, 18, 20],
      "default_tip_percent": 18,
      "custom_tip_enabled": true
    }
  }'

The response's url is the link to share. Buyers who open it see this checkout:

Flint hosted checkout for a $120.00 garden cleanup. Under the card form, an Add a tip row offers 15% ($18.00), 18% ($21.60), 20% ($24.00), Custom, and No tip. 18% is selected, the summary shows a $21.60 Tip line, and the button reads Pay $141.60.

The buyer gets three preset buttons with the dollar amount already worked out, a custom amount, and No tip, directly above the Pay button. default_tip_percent preselects 18%. Choosing another option updates the Tip line and the total on the same page, and the buyer can change it until they pay. There is no tip step to build and no session to recreate.

On small totals a percentage reads as pennies. Switch the presets to three fixed amounts with the same block:

JSON
"tip": {
  "enabled": true,
  "smart_tips_enabled": true,
  "smart_tip_money_options": [
    {"amount": 100, "currency": "USD"},
    {"amount": 200, "currency": "USD"},
    {"amount": 500, "currency": "USD"}
  ],
  "custom_tip_enabled": true
}

To skip the code entirely, turn on Ask for a tip at checkout under Settings, then Tipping, in the dashboard. One-time checkouts and payment links that don't send their own tip block use those settings, and fields you leave out of a tip block inherit them one at a time.

The tip on the order

The tip is not a product row. It is its own entry in the order's tips, counted in pricing_amounts.requested_tip_money, so the order always tells the sale from the gratuity. After the buyer picks 18%:

JSON
"tips": [{
  "order_tip_id": "tip_1kmn0aExample",
  "status": "requested",
  "percent": 18,
  "effective_amount_money": {"amount": 2160, "currency": "USD"},
  "settled_amount_money": {"amount": 0, "currency": "USD"},
  "refunded_money": {"amount": 0, "currency": "USD"},
  "payment_intent_allocations": [],
  "value_settlement_allocations": []
}],
"pricing_amounts": {
  "subtotal_money": {"amount": 12000, "currency": "USD"},
  "discount_money": {"amount": 0, "currency": "USD"},
  "charge_money": {"amount": 0, "currency": "USD"},
  "tax_money": {"amount": 0, "currency": "USD"},
  "additive_tax_money": {"amount": 0, "currency": "USD"},
  "inclusive_tax_money": {"amount": 0, "currency": "USD"},
  "requested_tip_money": {"amount": 2160, "currency": "USD"},
  "total_money": {"amount": 14160, "currency": "USD"}
}
A $120.00 visit with an 18% tip
  • Subtotalpricing_amounts.subtotal_money
    $120.00
  • Tip, 18%pricing_amounts.requested_tip_money
    $21.60
  • Totalpricing_amounts.total_money
    $141.60
  • Promotions discount items, never the tip. A fixed tip stays exactly as the buyer chose it. A percent tip is a percent of the subtotal after discounts, without charges or tax, so it follows what the buyer pays for the service.
  • Tips are never taxed. There is no tax code to set, and changing the tip leaves the order's tax as it is.
  • Collected tips have their own total. When the payment succeeds the tip's status becomes settled, and settlement_amounts.settled_tip_money is the number to reconcile and pay staff from. The orders_itemized_v1 report has a requested_tip_money column per order.

Refund the service, keep the tip

A refund that names line items returns those items and their tax, and leaves the tip where it is:

cURL
curl -X POST https://api.withflintpay.com/v1/refunds \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: refund-garden-cleanup-service" \
  -d '{
    "refund_type": "targets",
    "order_id": "ord_1kmn0aExample",
    "reason": "requested_by_customer",
    "line_items": [{"order_line_item_id": "li_1kmn0aExample"}]
  }'

A refund by amount returns items and charges before it reaches the tip. Every refund reports its tip share in refunded_tip_money, and the tip's status moves to partially_refunded or refunded when part or all of it goes back. See Refunds.

A tip from your own UI

If your app collects the tip instead of the hosted page, such as a restaurant POS or online ordering app, set it on the order as a percent or a fixed amount_money:

cURL
curl -X PATCH https://api.withflintpay.com/v1/orders/ord_1kmn0aExample \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{"requested_tip": {"calculation_type": "percent", "percent": 18}}'

The same rules apply, and sending the buyer's latest choice again replaces the tip rather than adding a second one. The tips and fees guide covers both paths, along with delivery and service fees on the same order.

Stripe and Flint online tipping, side by side

Stripe Checkout and Payment LinksFlint checkout and payment links
Tip prompt on the hosted payment pageNoneThree presets, a custom amount, and No tip
Percent presetsYour own UI and server mathtip_percent_options
Fixed-amount presetsOptional items, in multiples of one pricesmart_tip_money_options
Custom tip amountPay-what-you-want only, with no other itemscustom_tip_enabled
Buyer changes the tip while payingOn a page you build, with Checkout elements or the Payment ElementOn the hosted page, until they pay
Promotion codes and the tipDiscounted unless every coupon uses applies_toNever discounted
Tax and the tipPreset tax code unless you set NontaxableNever taxed
Where the tip is recordedA product row, or metadata you wrotetips on the order and settled_tip_money
Refund the sale, keep the tipYou compute the amountName the line items with refund_type: "targets"
Tip share of a refundYou work it outrefunded_tip_money on the refund

Questions, answered

Can you add a tip to a Stripe Payment Link?

Not as a tip prompt. Payment Links have no tipping setting, and "Customers choose what to pay" lets the buyer set the whole price, which can't share a link with other line items. The closest options are a fixed-amount Tip product as an optional item on the link, or a second pay-what-you-want link after the purchase.

Does Stripe Checkout support tipping?

Stripe Checkout has no tip parameter. Your page collects the tip and sends it as a line item, either when you create the session or, with Checkout elements, by updating the session's line items during checkout. Stripe's tipping features, on-reader and on-receipt, belong to Terminal for in-person payments.

Can I use Stripe Terminal tipping for online payments?

No. On-reader tipping shows its three suggestions on a physical card reader, and on-receipt tipping adds the tip when an in-person authorization is captured. Neither applies to Checkout, Payment Links, or the Payment Element.

How do I offer 15%, 18%, and 20% tip buttons with Stripe?

You build them in your own UI. Compute each percentage from your cart subtotal on your server, then send the chosen amount to Checkout as a line item or add it to the PaymentIntent amount. Stripe's hosted pages do no percentage math on a tip.

Does Stripe Tax charge tax on a tip line item?

It can. A line item without a tax code gets your account's preset product tax code. Assign the Nontaxable code, txcd_00000000, to the tip line if the gratuity should not be taxed.

Can a customer add a tip after paying online with Stripe?

Not on the same payment. Adding a tip after authorization is on-receipt tipping, a Terminal feature for in-person payments in the US. Online, a later tip is a second payment, such as a pay-what-you-want Payment Link on your confirmation page.

Can I refund a service and keep the tip?

On Stripe, a refund is an amount against the payment, so you work out the non-tip amount from your own records and refund that. On Flint, a refund with refund_type targets names the line items to refund and leaves the tip in place, and every refund reports its tip share in refunded_tip_money.

Is there a hosted checkout with a tip prompt built in?

Yes. On Flint, a tip block on a payment link or checkout session adds three preset buttons with the dollar amounts worked out, an optional custom amount, and No tip to the hosted payment page. The tip is recorded on the order separately from the items, is never taxed, and promotions don't discount it.

Sources

Accessed October 28, 2026.