Payment Intents are the backbone of Stripe's modern payment flow, but they're opaque by default. You create one, pass it to your frontend, and then what? Without proper tracking, you'll lose visibility into payment status, failed transactions, and incomplete checkouts. Here's how to set up tracking that actually works.
Create and Track Payment Intents with Webhooks
Webhooks are the most reliable way to track Payment Intent status changes in real-time. Every status transition—from requires_payment_method to succeeded—fires an event you can listen to.
Create a Payment Intent on your backend
When a customer initiates checkout, create a Payment Intent in your backend. Pass the client_secret to your frontend so it can confirm the payment.
const stripe = require('stripe')('sk_live_...');
// In your backend API endpoint
const paymentIntent = await stripe.paymentIntents.create({
amount: 2000, // $20.00 in cents
currency: 'usd',
metadata: {
user_id: '12345',
order_id: 'order_abc'
}
});
// Send client_secret to frontend
res.json({ clientSecret: paymentIntent.client_secret });Listen for payment_intent.succeeded webhook events
Enable the payment_intent.succeeded event in your Stripe Dashboard under Webhooks > Endpoints. This fires when a payment actually succeeds, giving you a reliable signal to fulfill the order.
const express = require('express');
const app = express();
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object;
console.log(`Payment succeeded: ${paymentIntent.id}`);
fulfillOrder(paymentIntent.metadata.order_id);
}
res.json({received: true});
});Track failed payments with payment_intent.payment_failed
Listen for payment_intent.payment_failed to catch declined transactions. This includes soft declines (insufficient funds) that customers might retry.
if (event.type === 'payment_intent.payment_failed') {
const paymentIntent = event.data.object;
console.error(`Payment failed: ${paymentIntent.id}`, {
status: paymentIntent.status,
last_error: paymentIntent.last_payment_error,
amount: paymentIntent.amount
});
sendEmail(paymentIntent.metadata.user_email, {
subject: 'Payment Failed',
message: `We couldn't process your payment. Error: ${paymentIntent.last_payment_error.message}`
});
}payment_intent.succeeded. Listen for payment_intent.amount_capturable_updated, payment_intent.canceled, and charge.refunded to catch edge cases.Query Payment Intents Programmatically
Webhooks give you real-time updates, but sometimes you need to pull Payment Intent data on-demand. Use Stripe's search and retrieve APIs to check status, filter by metadata, or debug incomplete transactions.
Retrieve a specific Payment Intent by ID
If you know the Payment Intent ID (e.g., from your order database), fetch it directly to check its current status without waiting for webhooks.
const paymentIntent = await stripe.paymentIntents.retrieve('pi_1A1A1A1A1A1A1A1A');
console.log({
id: paymentIntent.id,
status: paymentIntent.status, // 'succeeded', 'requires_action', etc.
amount: paymentIntent.amount,
currency: paymentIntent.currency,
created: new Date(paymentIntent.created * 1000)
});List Payment Intents with filters and pagination
Fetch multiple Payment Intents by status, date range, or customer. Use limit and starting_after for pagination when scanning large datasets.
// Get all succeeded payments from the last 7 days
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60;
const intents = await stripe.paymentIntents.list({
status: 'succeeded',
created: { gte: sevenDaysAgo },
limit: 100,
expand: ['data.customer']
});
intents.data.forEach(intent => {
console.log(`${intent.id}: $${(intent.amount / 100).toFixed(2)} - ${intent.status}`);
});Search Payment Intents using the Search API
Use the Search API for complex queries across Payment Intents. Search by metadata, customer ID, amount, or combine multiple filters with AND/OR logic.
// Find all failed payment intents for a specific user
const failedIntents = await stripe.paymentIntents.search({
query: "status:'requires_payment_method' AND metadata['user_id']:'12345'",
limit: 50
});
console.log(`Found ${failedIntents.data.length} payment intents that need attention`);
failedIntents.data.forEach(intent => {
console.log({
id: intent.id,
customer: intent.customer,
amount: intent.amount,
status: intent.status
});
});Build Real-Time Payment Tracking
Once you're capturing Payment Intent events, log them to your database and build aggregations. This gives you dashboards, alerts, and the ability to debug individual transactions.
Log payment events to your database
Store each webhook event (succeeded, failed, requires_action) in your database with relevant metadata. This becomes your source of truth for payment history and metrics.
// Log every payment_intent event to your database
const logPaymentEvent = async (event) => {
const intent = event.data.object;
await db.query(`
INSERT INTO payment_events (event_id, payment_intent_id, status, amount, timestamp, user_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (event_id) DO NOTHING
`, [event.id, intent.id, event.type.split('.')[1], intent.amount, new Date(event.created * 1000), intent.metadata.user_id]);
};
// Then aggregate by hour for your dashboard
const hourlyMetrics = await db.query(`
SELECT
DATE_TRUNC('hour', timestamp) AS hour,
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'succeeded') as succeeded,
COUNT(*) FILTER (WHERE status = 'payment_failed') as failed,
SUM(amount) FILTER (WHERE status = 'succeeded') as revenue_cents
FROM payment_events
GROUP BY hour
ORDER BY hour DESC
LIMIT 24
`);
return hourlyMetrics;ON CONFLICT DO NOTHING, duplicate events won't break your data.Common Pitfalls
- Relying on succeeded webhooks as synchronous. They can be delayed by seconds. Design your backend to be idempotent—handle the same webhook multiple times without double-charging or double-fulfilling orders.
- Ignoring payment_intent.requires_action. Payments requiring 3D Secure or other customer authentication trigger this status. If you only listen for succeeded, customers get stuck and abandon checkout.
- Querying too aggressively from the frontend. Don't poll payment intents every 500ms. Use webhooks or a polling interval of at least 5-10 seconds if you absolutely must poll.
- Storing client_secret in your database. The client_secret should only exist in memory on the frontend or be passed directly from your backend. If compromised, attackers can confirm payments.
Wrapping Up
Payment Intents are powerful, but you need proper tracking to use them well. Webhooks give you real-time visibility; search and retrieve APIs give you historical queries and debugging. Start with webhooks for production, then layer in dashboards and queries as your payment volume grows. If you want to track Payment Intent metrics alongside customer engagement and product usage across all your tools, Product Analyst can help.