Payment links: Payments & Stripe lessons from production
Payment links are one of the most underrated tools in the Stripe arsenal, yet they solve a real problem that nearly every payment-enabled business faces: how do you accept payments without building custom checkout flows? Whether you're invoicing a client, selling a digital product, or handling a one-time transaction, payment links let you generate a shareable URL that handles the entire payment experience. We've learned across multiple production systems that understanding when and how to use payment links—and equally important, how to orchestrate fulfillment around them—can dramatically reduce your integration complexity.
Create links in the dashboard or API
Stripe gives you two paths to create payment links: the dashboard for manual, ad-hoc scenarios, and the API for programmatic generation at scale. For invoices or quick sales where you're handling things manually, the dashboard is fast. But in production systems, we almost always reach for the API because it integrates with your business logic and allows you to embed payment links into emails, notifications, or your product interface automatically.
Creating a payment link via the API is straightforward. You're building a line items object that describes what's being sold, setting a price, and optionally configuring completion behavior:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
async function createPaymentLink(productName, priceCents, customerEmail) {
const link = await stripe.paymentLinks.create({
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: productName,
description: `One-time purchase of ${productName}`,
},
unit_amount: priceCents,
},
quantity: 1,
},
],
metadata: {
customer_email: customerEmail,
created_at: new Date().toISOString(),
},
after_completion: {
type: 'redirect',
redirect: {
url: 'https://yourdomain.com/thank-you',
},
},
});
return link.url;
}
// Usage
const paymentUrl = await createPaymentLink(
'Annual Subscription',
99900,
'customer@example.com'
);
console.log(`Share this link: ${paymentUrl}`);
The key fields here are line_items (what you're charging for), metadata (which we'll cover next), and after_completion (where the customer lands after they pay). One thing to remember: payment links are not tied to a customer record by default. They're public URLs, so anyone with the link can theoretically pay it. If you need customer context, lean on metadata and webhooks.
In a real scenario, we might generate a payment link when an invoice is created in your system, store the link URL in your database, and send it to the customer via email. The link lives for 24 hours by default, though you can customize expiration:
const link = await stripe.paymentLinks.create({
line_items: [...],
expires_at: Math.floor(Date.now() / 1000) + 86400 * 7, // 7 days
});
Great for invoices and quick sales
Payment links shine in specific use cases, and understanding where they belong (and where they don't) keeps your architecture clean. Invoices are the obvious win: a freelancer sends a link to a client, the client clicks, enters payment details, and money lands in your account. No custom form, no checkout logic to maintain. We've seen teams save weeks of work by reaching for payment links instead of building a one-off payments UI.
Beyond invoices, payment links work well for:
- Digital product sales – downloadable courses, ebooks, or templates where the purchase and delivery are decoupled.
- One-time purchases outside your app – think physical meetups, workshops, or limited-run events that need a quick payment mechanism.
- Affiliate or referral payouts – you can generate a link per referrer and track payouts via metadata.
- Donation or tip mechanisms – nonprofits and creators use them to collect recurring or one-time support.
Where payment links fall short: subscription management (where recurring billing requires more complex state), customer-authenticated purchases (where you need to verify identity before charging), or experiences where the payment flow is deeply embedded in your product. For those, you want Stripe Checkout or a custom Payment Element integration.
A practical example: imagine you run a SaaS platform where customers can purchase add-ons. When they click "Buy Pro Features," your backend creates a payment link and redirects them. They pay, the webhook fires (we'll get to that), and their account upgrades. No custom checkout page needed:
// In your API handler
app.post('/api/create-addon-link', authenticateUser, async (req, res) => {
const { addonId, userId } = req.body;
const link = await stripe.paymentLinks.create({
line_items: [
{
price: addonId, // Pre-created price ID in Stripe
quantity: 1,
},
],
metadata: {
userId,
addonId,
},
after_completion: {
type: 'redirect',
redirect: {
url: `https://yourdomain.com/dashboard?addon_purchased=true`,
},
},
});
res.json({ url: link.url });
});
Webhooks still drive fulfillment
This is where we see the most confusion in production systems. A payment link handling payment is only half the job. The other half—verifying the payment, unlocking access, sending confirmation emails, updating your database—requires webhooks. The after_completion redirect is nice for user experience, but it is not a reliable fulfillment trigger. A customer can close the browser, lose internet, or have a redirect fail. Your webhook is the source of truth.
When a payment link payment succeeds, Stripe sends a payment_intent.succeeded webhook event. Your code needs to listen for it, verify the payment, and then execute your business logic:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Express webhook handler
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.sendStatus(400);
}
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object;
// Extract metadata (remember this from payment link creation?)
const { userId, addonId } = paymentIntent.metadata || {};
if (!userId || !addonId) {
console.error('Missing metadata on successful payment');
return res.sendStatus(400);
}
try {
// 1. Update your database
await db.userAddons.create({
userId,
addonId,
stripePaymentIntentId: paymentIntent.id,
chargedAmount: paymentIntent.amount_received,
paidAt: new Date(paymentIntent.created * 1000),
});
// 2. Send confirmation email
await sendEmail({
to: paymentIntent.receipt_email,
subject: 'Your addon purchase is complete',
template: 'addon-confirmation',
data: { addonId },
});
// 3. Log for analytics
console.log(`User ${userId} purchased addon ${addonId}`);
} catch (err) {
console.error('Fulfillment error:', err);
return res.sendStatus(500);
}
}
res.sendStatus(200);
});
Notice the flow: payment link → customer pays → Stripe fires webhook → your code reads metadata → you fulfill. The metadata field is your bridge between a stateless payment link and your business logic. Without it, you have no way to know which customer paid or what they paid for.
One production lesson: always make your webhook handlers idempotent. Stripe may retry a webhook if it doesn't receive a 200 response within a few seconds. If you don't guard against duplicate processing, you'll charge customers twice or send duplicate emails. A simple approach is to check if the payment has already been processed before executing fulfillment:
// Check if we've already processed this payment
const existingRecord = await db.userAddons.findOne({
stripePaymentIntentId: paymentIntent.id,
});
if (existingRecord) {
console.log(`Payment ${paymentIntent.id} already processed`);
return res.sendStatus(200); // Still return 200 so Stripe stops retrying
}
// ... proceed with fulfillment
Track with metadata
Metadata is how you connect a payment link back to your business domain. Every payment link you create should include metadata that answers: who is this for, what are they buying, and what context do I need to fulfill this?
We've found it useful to structure metadata consistently across your systems. Here's a pattern we use in production:
| Metadata Field | Purpose | Example Value |
|---|---|---|
userId |
Internal customer identifier | user_12345 |
invoiceId |
If this is an invoice payment | inv_67890 |
orderType |
Category of purchase | addon, subscription_upgrade, one-time |
externalRef |
Reference to external system | project_abc123 |
createdAt |
When the link was generated | 2025-01-15T10:30:00Z |
When the webhook fires, you have all the context you need to route fulfillment:
const { userId, invoiceId, orderType, externalRef } = paymentIntent.metadata || {};
if (orderType === 'addon') {
await activateAddon(userId, externalRef);
} else if (orderType === 'subscription_upgrade') {
await upgradeSubscription(userId);
} else if (invoiceId) {
await markInvoiceAsPaid(invoiceId);
}
Metadata also makes debugging and auditing much easier. If a customer says they paid but don't have access, you can search for their payment in Stripe, see the metadata, and understand what should have happened. Many production issues are resolved just by reading the metadata and comparing it to your database state.
Common mistakes
Relying on the redirect instead of webhooks: Redirects are not guaranteed. Always use webhooks as your source of truth for fulfillment. The redirect is just a nice UX touch.
Not including enough metadata: Vague or missing metadata means you can't connect a payment back to your business. When something goes wrong (and it will), you're stuck investigating. Be explicit and generous with metadata.
Creating payment links without expiration in public contexts: If you post a payment link in an email or on a page, set an expiration so old links don't float around indefinitely. Default 24 hours is usually fine, but check your use case.
Failing to handle webhook retries: Stripe retries failed webhooks for up to 3 days. If your handler doesn't guard against duplicates, you'll have a bad time in production. Always check if the payment was already processed.
Not testing the full flow: Use Stripe's test mode to create payment links, pay them with test cards, and verify webhooks fire locally. Many teams skip this and ship broken fulfillment logic.
Key takeaways
- Payment links eliminate the need for custom checkout UI for invoices, one-time purchases, and quick sales. Use the API for programmatic generation tied to your business logic.
- Webhooks, not redirects, drive reliable fulfillment. Always listen for
payment_intent.succeededand execute your business logic there. - Metadata is your bridge between a stateless payment link and your domain. Structure it consistently and include enough context to route fulfillment correctly.
- Make webhook handlers idempotent to survive Stripe retries. Check if a payment was already processed before fulfilling.
- Payment links are not a catch-all; they work best for invoicing and simple one-time sales. For subscriptions or deeply embedded experiences, consider Checkout or Payment Element.
- Test the full payment link → webhook → fulfillment flow in test mode before shipping. This catches most integration bugs early.






Comments
Loading comments…