Payment Update Links
How to generate, embed, and manage tokenized payment update links that direct customers to the LostChurn portal.
Payment update links are the primary mechanism for directing customers from a dunning message to the customer portal. Each link contains a cryptographically signed token that authenticates the customer without requiring a password or login.
Link Format
A payment update link follows this structure:
https://your-domain.com/portal/verify?token=v1:<raw_token>:<customer_id>:<merchant_id>:<expires_at>.<hmac_signature>The token is an HMAC-SHA256 signed envelope containing:
| Segment | Description |
|---|---|
v1 | Token format version |
raw_token | 48-character hex session identifier from SpacetimeDB |
customer_id | The customer's internal identifier |
merchant_id | Your merchant account identifier |
expires_at | Unix epoch milliseconds when the link expires |
hmac_signature | Base64url-encoded HMAC-SHA256 signature covering all fields above |
The signature is computed over the entire data portion (v1:raw_token:customer_id:merchant_id:expires_at) using your PORTAL_TOKEN_SECRET. This means an attacker cannot alter any field -- including the expiration time or customer ID -- without invalidating the signature.
Generating Links
Links are generated server-side when a dunning campaign step executes. The signPortalToken() function produces the signed token, which is then embedded in the portal URL:
import { signPortalToken } from '@/lib/portal-token';
const signedToken = signPortalToken({
rawToken: session.token, // from SpacetimeDB customer_session
customerId: session.customerId, // SpacetimeDB customer ID
merchantId: session.merchantId, // SpacetimeDB merchant ID
expiresAt: session.expiresAt, // Unix ms timestamp
});
const updateUrl = `https://your-domain.com/portal/verify?token=${signedToken}`;Never generate portal tokens on the client side. The PORTAL_TOKEN_SECRET must remain server-side only. Exposing it would allow anyone to forge valid portal links for any customer.
Embedding in Dunning Emails
When building email campaigns, use the {{update_url}} template variable to insert the portal link. LostChurn automatically generates a fresh signed token for each email send and populates this variable.
<a href="{{update_url}}" style="...">
Update Your Payment Method
</a>The variable is available in all email template formats -- HTML, plain text, and MJML. A typical dunning email CTA might look like:
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius: 6px; background: #2563eb; padding: 12px 24px;">
<a href="{{update_url}}" style="color: #ffffff; text-decoration: none; font-weight: 600;">
Update Payment Method
</a>
</td>
</tr>
</table>Embedding in SMS Messages
For SMS campaigns, use the same {{update_url}} variable. LostChurn automatically shortens the URL when sending via SMS to stay within character limits:
Your payment of {{amount}} failed. Update your card here: {{update_url}}SMS links use the same signed token as email links. The shortened URL redirects through your domain before landing on the portal verification page, so the token is never exposed to a third-party URL shortener.
Link Expiration and Security
Expiration
Every portal link has a time-to-live (TTL) set at generation time. Once the TTL passes, the link is rejected even if the HMAC signature is valid. A 5-minute clock-skew tolerance is built in to account for minor time differences between servers.
Expired links show a clear error message in the portal: "This link has expired. Please request a new one."
Security Properties
- One customer, one link -- Each token is bound to a specific customer ID and merchant ID. A token generated for one customer cannot be used to access another customer's portal.
- Tamper-proof -- Any modification to the token (changing the customer ID, extending the expiration, altering the session identifier) invalidates the HMAC signature.
- Constant-time verification -- Signature comparison uses
timingSafeEqualto prevent timing side-channel attacks. - No replay after expiration -- Even if a token is intercepted, it becomes useless after the TTL expires.
- Fail closed -- If
PORTAL_TOKEN_SECRETis not configured, all token verification fails. The system never silently downgrades to unsigned tokens.
For the full security model, see the Security documentation.
Custom Redirect After Successful Update
After a customer successfully updates their payment method through the Stripe Billing Portal, they are redirected back to the LostChurn portal's payment methods page with a ?updated=1 query parameter. The portal displays a success confirmation:
Payment method updated -- Your new payment details have been saved. Future charges will use your updated card.
You can customize the redirect destination by configuring the return_url in your portal settings. Common options include:
- Portal methods page (default) -- The customer stays in the LostChurn portal and sees the confirmation.
- Your app's billing page -- Redirect the customer back into your product.
- A custom thank-you page -- Show a branded confirmation with next steps.
Testing Links in Sandbox Mode
During development and QA, you can test portal links without a live Stripe integration:
1. Generate a Test Token
Use the signPortalToken() function with test data:
import { signPortalToken } from '@/lib/portal-token';
const testToken = signPortalToken({
rawToken: 'a'.repeat(48), // dummy 48-char hex
customerId: '12345',
merchantId: '67890',
expiresAt: Date.now() + 3600000, // 1 hour from now
});
console.log(`/portal/verify?token=${testToken}`);2. Set the Environment Variable
Make sure PORTAL_TOKEN_SECRET is set in your .env.local:
openssl rand -hex 32Add the output to .env.local:
PORTAL_TOKEN_SECRET=<your-generated-hex>3. Verify the Flow
- Start the development server.
- Open the generated URL in your browser.
- The verification page validates the token and redirects to the portal dashboard.
- Click "Update Payment Method" to confirm the Stripe redirect flow (requires
STRIPE_SECRET_KEYfor the full flow; without it, the API returns a501stub response).
In sandbox mode without STRIPE_SECRET_KEY, clicking "Update Payment Method" returns a clear error explaining that Stripe is not configured. This lets you test the token verification and portal UI independently of Stripe.
4. Test Expiration
Generate a token with an expiresAt in the past to verify that expired links are properly rejected:
const expiredToken = signPortalToken({
rawToken: 'b'.repeat(48),
customerId: '12345',
merchantId: '67890',
expiresAt: Date.now() - 60000, // 1 minute ago
});The portal should display: "This link has expired. Please request a new one."
Next Steps
- What Customers See -- Overview of the portal experience
- Email Campaigns -- Configure the dunning emails that carry update links
- Security -- Full details on token signing, verification, and threat model