LostChurn Docs
Customer Portal

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.

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:

SegmentDescription
v1Token format version
raw_token48-character hex session identifier from SpacetimeDB
customer_idThe customer's internal identifier
merchant_idYour merchant account identifier
expires_atUnix epoch milliseconds when the link expires
hmac_signatureBase64url-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.

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.

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 timingSafeEqual to 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_SECRET is 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.

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 32

Add the output to .env.local:

PORTAL_TOKEN_SECRET=<your-generated-hex>

3. Verify the Flow

  1. Start the development server.
  2. Open the generated URL in your browser.
  3. The verification page validates the token and redirects to the portal dashboard.
  4. Click "Update Payment Method" to confirm the Stripe redirect flow (requires STRIPE_SECRET_KEY for the full flow; without it, the API returns a 501 stub 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

On this page