Why Third-Party Integrations Make or Break Your Website
One integration that pays for itself
Most "essential integrations" posts are listicles: pick a CRM, pick an analytics platform, pick a payment processor. The lists are interchangeable, and they don't help you build anything. So this post takes one integration that genuinely matters for a small business website, walks through it end to end, and uses it to draw out the principles that apply to any third-party service you'll plug into your site afterwards.
The integration is Stripe Checkout, with a webhook that writes the completed order to your own database and triggers a confirmation email. It's the standard payments setup for a small business that sells anything online, and once you've done it once, every other webhook-driven integration looks the same.
Why Stripe Checkout, not a payment form on your site
Stripe gives you two main paths: Stripe Checkout, which is a hosted payment page Stripe runs on their domain, and Stripe Elements, which is an embeddable form you host on your own page.
Checkout is the right default for almost everyone. Three reasons.
First, PCI scope. The moment a card number touches your origin, you're in PCI DSS scope, which is a serious compliance burden. With Checkout, the card details never reach your server; you're in the lightest tier of scope (SAQ A) with almost no work. Elements gets you into a slightly heavier tier.
Second, conversion. Stripe runs A/B tests across millions of checkout sessions. Their hosted page handles wallets (Apple Pay, Google Pay, Link), local methods (BACS, SEPA, iDEAL), saved cards, and 3D Secure flows correctly. Replicating that in Elements takes weeks of work and you'll still be behind.
Third, maintenance. New payment methods, new SCA rules, new fraud signals: Checkout absorbs them. Elements does some of this and you handle the rest. For a small business site, the engineering hours saved here are real money.
The case for Elements is real but narrow: tightly branded checkout flows, multi-step forms where payment is one step among several, embedded marketplaces. If you're not sure, you want Checkout.
The high-level shape
Three pieces of code:
- ›A route that creates a Stripe Checkout Session and redirects the user to it.
- ›A webhook that listens for
checkout.session.completed, verifies the signature, writes the order to your database, and sends the confirmation email. - ›A "thanks, we got it" page the user lands on when payment finishes.
The webhook is the part most teams get wrong. Everything below points at it.
Creating the session
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const { priceId, customerEmail } = await req.json()
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
customer_email: customerEmail,
success_url: `${process.env.SITE_URL}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/pricing`,
metadata: { source: 'pricing-page' },
})
return Response.json({ url: session.url })
}
Pass anything you need to look up later in metadata. The priceId should reference a Price object you've created in the Stripe Dashboard rather than passing a raw amount in code; this makes price changes a Dashboard action rather than a deploy.
The webhook, with the parts that matter
import Stripe from 'stripe'
import { sql } from '@/lib/db'
import { sendOrderEmail } from '@/lib/email'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(req: Request) {
const signature = req.headers.get('stripe-signature')
const body = await req.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature!, webhookSecret)
} catch (err) {
return new Response('Invalid signature', { status: 400 })
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session
// Idempotency. Stripe will retry. Don't double-write.
const inserted = await sql`
INSERT INTO orders (stripe_event_id, stripe_session_id, email, amount_total, currency)
VALUES (${event.id}, ${session.id}, ${session.customer_details?.email},
${session.amount_total}, ${session.currency})
ON CONFLICT (stripe_event_id) DO NOTHING
RETURNING id
`
if (inserted.length > 0) {
await sendOrderEmail({
to: session.customer_details!.email!,
sessionId: session.id,
})
}
}
return new Response('ok', { status: 200 })
}
Two things to call out.
Signature verification is non-negotiable. Without it, anyone who finds your webhook URL can post fake events to it and have you record fake orders. Stripe sends a signature header, you verify it with the secret you got when you registered the endpoint. Don't skip this in development; use the Stripe CLI to forward signed events to localhost.
Stripe webhooks are eventually consistent and retried. They will fire more than once for the same event in some failure modes. The unique constraint on stripe_event_id is what protects you from sending two confirmation emails for one purchase. Always store the event ID, always upsert. This is the single biggest lesson for any webhook-driven integration: idempotency is a property of your code, not a property of the vendor.
There's a related point. The success_url runs immediately when payment finishes, but the webhook may arrive a few seconds later. Don't put fulfilment logic on the success page. Put it in the webhook. The success page is for telling the user that their payment worked.
What failures look like
You will see, over the lifetime of the integration:
- ›Webhooks that time out because your handler did too much synchronously. Keep the handler under a few seconds, push slow work onto a queue.
- ›Duplicate events from Stripe retrying after a flaky response. The idempotency above handles them.
- ›Missing events because your endpoint returned a non-2xx response. Check the Stripe Dashboard's webhook log; the failed deliveries are listed with their reasons.
- ›Local events you can't reproduce because the production webhook secret differs from staging. Always use environment-specific secrets, and never share them across environments.
The Stripe Dashboard's webhook log is the first place to look when something seems off. It tells you what was sent, what your endpoint returned, and how many retries are queued.
What this teaches you about other integrations
The pattern below is true of any third-party service that pushes events at your site (CRM webhooks, email-platform bounces, calendar booking events):
- ›Verify signatures. Reject anything that fails.
- ›Treat events as retried. Make handlers idempotent on the event ID the vendor sends.
- ›Don't depend on event ordering unless the vendor explicitly guarantees it; Stripe does not.
- ›Push slow work out of the handler. Acknowledge fast.
- ›Log enough that you can reconstruct what happened from the Dashboard side and your side.
Get those right once with Stripe, and the next webhook integration takes an afternoon instead of a week.
The summary
Stripe Checkout for the user-facing flow, a signed webhook for fulfilment, idempotency keyed on the event ID, and slow work off the hot path. That's the whole shape of a payments integration that won't quietly lose orders or send duplicate emails. The same shape applies to most other event-driven integrations on your site, which is why getting it right once is worth the time.