Stripe Card Update Link Not Working: How to Fix It (And Automate Card Updates)
Stripe billing portal sessions expire after 24 hours, the customer portal must be explicitly configured with payment_method_update enabled, and the session must be created for the correct customer ID. If any of these are wrong, the card update link will 404, show a blank page, or display "session expired." The permanent fix is to generate fresh one-click card update links automatically for every failed payment.
Why Card Update Links Break
Stripe's billing portal is the official way to let customers update their payment method. But there are 5 common reasons the link fails, and Stripe's error messages are unhelpful, often showing a generic "something went wrong" page instead of telling you what's actually misconfigured.
Billing portal not configured. You must create a portal configuration before generating sessions. If you've never called stripe.billingPortal.configurations.create or configured it in the Dashboard, sessions will fail silently.
Portal session expired. Sessions are valid for exactly 24 hours after creation. If you email a link and the customer clicks it 25 hours later, they'll see "This session has expired." There's no way to extend this window.
Wrong customer ID. The session is tied to a specific Stripe customer. If you pass cus_abc but the customer's actual ID is cus_xyz, the link will work but show the wrong account. or fail entirely if the customer doesn't exist.
Payment method update not enabled. The portal configuration has a features object where payment_method_update must be explicitly set to true. If it's false or missing, the customer sees the portal but can't change their card.
Custom domain mismatch. If you've configured a custom domain for your billing portal but your DNS isn't set up correctly, links will resolve to a dead page or show SSL errors.
// List all portal configurations to see what's enabled
const configs = await stripe.billingPortal.configurations.list({
limit: 5,
});
for (const config of configs.data) {
console.log('Config ID:', config.id);
console.log('Active:', config.is_default);
console.log('Payment method update:',
config.features.payment_method_update.enabled
);
console.log('---');
}
// If no configs exist or payment_method_update is false,
// that's why your links aren't working.Warning: Stripe does not send any webhook or notification when a portal session expires. If you're emailing card update links to customers during dunning, a 24-hour window means links sent at 5pm on Friday are dead by Saturday evening. Customers who check email on weekends will hit expired links.
Quick Fix (Manual)
If your card update links aren't working right now, follow these steps to diagnose and fix the issue:
Verify your portal configuration exists and has payment_method_update enabled. Run the configuration check code above or go to Dashboard > Settings > Billing > Customer portal.
Create a fresh portal session with the correct customer ID and a valid return_url. The return_url must be an absolute URL (https://). relative paths will fail.
Test the session URL immediately in an incognito browser window to confirm it works before sending it to a customer.
If using custom domains, verify DNS with: dig CNAME billing.yourdomain.com. It should point to portal.stripe.com.
For production, always generate sessions on-demand (not cached) so the 24-hour expiry starts when the customer actually needs it.
// First: create or update your portal configuration
const configuration = await stripe.billingPortal.configurations.create({
business_profile: {
headline: 'Manage your subscription',
},
features: {
payment_method_update: {
enabled: true, // This is the critical flag
},
// Optional: enable other features
subscription_cancel: {
enabled: true,
mode: 'at_period_end',
},
invoice_history: {
enabled: true,
},
},
});
console.log('Portal config created:', configuration.id);// Generate a session for a specific customer
const session = await stripe.billingPortal.sessions.create({
customer: 'cus_xxx', // Must be the correct customer ID
return_url: 'https://yourapp.com/account', // Absolute URL required
// Optional: use a specific configuration
// configuration: 'bpc_xxx',
});
// This URL is valid for 24 hours
console.log('Card update link:', session.url);
// e.g. "https://billing.stripe.com/p/session/test_abc123"
// Send this URL via email immediately
await sendEmail({
to: customer.email,
subject: 'Update your payment method',
body: `Click here to update your card: ${session.url}`,
});// Better pattern: generate the session when the customer clicks
// This avoids the 24-hour expiry problem entirely
app.get('/update-card/:customerId', async (req, res) => {
const { customerId } = req.params;
// Verify the customer exists
try {
await stripe.customers.retrieve(customerId);
} catch (err) {
return res.status(404).json({ error: 'Customer not found' });
}
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `https://yourapp.com/account`,
});
// Redirect to the fresh session
res.redirect(303, session.url);
});
// Email the customer a link to YOUR endpoint instead
// e.g. https://yourapp.com/update-card/cus_xxx
// The session is created fresh when they click. never expiresTip: The on-demand pattern (generating the session at click time instead of at email time) is the gold standard. It eliminates the 24-hour expiry problem entirely. But you need to build and host the redirect endpoint yourself, handle auth, and manage the customer ID mapping.
Permanent Fix (Automated)
Building and maintaining card update infrastructure is surprisingly complex. You need: portal configuration management, session generation endpoints, dunning email delivery with fresh links, expiry handling, mobile-compatible links, and tracking to know if the customer actually updated their card. Most founders spend 2-3 days building this, and it still breaks when Stripe changes their portal API.
SaveMRR generates one-click card update links automatically for every customer with a failed payment. No portal configuration needed. No session expiry headaches. No redirect endpoint to build. When a payment fails, SaveMRR sends a dunning email sequence where every email contains a fresh, working card update link. The customer clicks, updates their card, and the subscription recovers. All without you writing a single line of code.
SaveMRR also tracks whether the customer opened the email, clicked the link, and actually updated their card. So you always know where each at-risk customer is in the recovery pipeline. If the first email doesn't work, SaveMRR escalates with follow-ups at 3, 7, 14, and 21 days with progressively urgent messaging. This prevents involuntary churn from expired or declined cards. You can also send card expiry reminders before the payment even fails.
// Without SaveMRR, you'd build all of this:
// 1. Portal configuration management
const config = await stripe.billingPortal.configurations.create({
features: { payment_method_update: { enabled: true } },
});
// 2. On-demand session generation endpoint
app.get('/update-card/:token', async (req, res) => {
const customer = await decodeToken(req.params.token);
const session = await stripe.billingPortal.sessions.create({
customer: customer.stripeId,
return_url: process.env.APP_URL + '/account',
});
res.redirect(303, session.url);
});
// 3. Webhook to detect failed payments
app.post('/webhooks/stripe', async (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, sig, secret);
if (event.type === 'invoice.payment_failed') {
const invoice = event.data.object;
const token = generateToken(invoice.customer);
const updateUrl = `https://yourapp.com/update-card/${token}`;
await sendDunningEmail(invoice.customer, updateUrl);
await scheduleDunningSequence(invoice.customer, updateUrl);
}
res.json({ received: true });
});
// 4. Dunning email sequence with fresh links
// 5. Card update tracking
// 6. Mobile-compatible link handling
// 7. Error handling, retries, logging...
// SaveMRR handles ALL of the above.
// Paste your API key → card update links work automatically.Tip: SaveMRR starts at $19/mo and the first $200 recovered free. For most SaaS products, that means you don't pay anything until SaveMRR has already proven it works. Run a free Revenue Scan to see how many customers you're losing to card update failures right now.
Related Stripe Billing Issues
Frequently Asked Questions
How long does a Stripe billing portal session last?
Exactly 24 hours from creation. There's no way to extend this. If you email a card update link and the customer doesn't click it within 24 hours, they'll see a "session expired" page. The fix is to generate the session on-demand when the customer clicks, rather than at email-send time. Use a redirect endpoint on your server that creates a fresh session at click time.
Can I customize the Stripe billing portal appearance?
Yes, but it's limited. You can set your business name, headline text, and branding colors via the portal configuration. You can also use a custom domain (e.g., billing.yourapp.com) instead of billing.stripe.com. However, you can't change the layout, add custom fields, or embed it in your own UI. The portal is always a full-page Stripe-hosted experience.
Do card update links work on mobile devices?
Yes, Stripe's billing portal is mobile-responsive. However, there are edge cases: some email clients (especially Outlook on Android) open links in an in-app browser that may not support Stripe's JavaScript properly. If you're getting reports of blank pages on mobile, recommend the customer open the link in their default browser (Chrome/Safari) instead of the email app's built-in browser.
How do I test card update links in Stripe test mode?
Create a portal session using your test mode API key (sk_test_xxx). The session URL will include 'test' in the path. You can use Stripe's test card numbers (4242424242424242 for success, 4000000000000002 for decline) to simulate the update flow. Note: test mode portal sessions still expire after 24 hours, so the expiry behavior is realistic in testing.
What happens if the customer updates their card but the payment still fails?
After a card update, Stripe does not automatically retry the failed invoice. You need to either: (1) manually retry via stripe.invoices.pay(), (2) set up a webhook listener for payment_method.attached to trigger a retry, or (3) wait for Stripe's next scheduled retry. SaveMRR handles this automatically. when a customer updates their card, SaveMRR immediately retries the outstanding invoice.
SaveMRR catches these automatically
Stop firefighting Stripe billing issues manually. Paste your API key, get a free Revenue Scan in 60 seconds, and let SaveMRR handle recovery automatically.
Run my free scan