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 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 have | Your path |
|---|---|
| Checkout Sessions on Stripe's hosted page | Ask first, send the tip as a line item |
| A Payment Link with no server code | Fixed tip add-ons with optional items |
| Checkout Sessions with Checkout elements on your page | Update the tip line while the buyer pays |
| The Payment Element on PaymentIntents | Update the PaymentIntent amount |
| The buyer wants to tip after paying | A tip jar link on your confirmation page |
| A card reader, in person | Terminal on-reader tipping |
| Percent buttons and a custom amount on a hosted payment page | Tipping 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:
// 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:
# 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:
// 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:
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:
// 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:
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.
After payment: a tip jar link on your confirmation page
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 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:
https://buy.stripe.com/...?prefilled_amount=500&client_reference_id=order_1042The 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.

