Back to Worldmonitor

Lifecycle emails addressed to the Dodo checkout email, not the Clerk login email, look like scams and failed sends

docs/solutions/architecture-patterns/welcome-email-checkout-identity-diverges-from-clerk-login-identity.md

2.10.012.4 KB
Original Source

Lifecycle emails addressed to the Dodo checkout email, not the Clerk login email, look like scams and failed sends

Context

A support email arrived on 2026-08-08 with the subject energy of a chargeback: "URGENT! Account not working after payment! … I hope this is not a scam!" The customer had paid, and the login screen told them the account was not known.

Nothing was broken. The payment succeeded, the entitlement was written correctly, and the welcome email was delivered and clicked. What failed was identity: the buyer received a "your subscription is now active" email at one address, tried to sign in with that address, and World Monitor had never heard of it. Their actual account was registered under a different address entirely.

This is not a regression, and no amount of staring at the entitlement pipeline finds it — the pipeline is green the whole way through. It is a design seam between the billing provider's notion of a customer (an email typed at checkout) and Clerk's notion of a user (an email that owns a sign-in). Any user who keeps aliases — and privacy-minded users keep many — can land on the wrong side of it.

The recipe below is the reusable part: it separates "our billing is broken" from "this person is signed in as someone else" in about five API calls, and it names the three ops traps that make the healthy case look like a failure.

Guidance

The diagnosis recipe for any "paid but can't log in" / "paid but no Pro" ticket

Run these in order. The point of the order is that each step narrows the failure class, and by step (d) you can already tell a real regression from an identity mix-up.

(a) Resolve the Dodo customer. Look them up by cus_… id if the ticket carries one, or by ?email= using every address in the thread. Note that the billing email here is whatever the buyer typed into the checkout form — it is not authenticated and it is not an account.

(b) Read metadata.wm_user_id on the subscription and payment records. This is the ground truth for "who bought this," and it is independent of any email. WorldMonitor stamps the signed Clerk identity onto the checkout session at convex/payments/checkout.ts:201-202:

ts
metadata.wm_user_id = user.userId;
metadata.wm_user_id_sig = await signUserId(user.userId);

The webhook side trusts it only when the HMAC verifies — tryResolveUserId in convex/payments/subscriptionHelpers.ts:667-680 returns the metadata userId on a valid signature, warns and falls through on an invalid or unsigned one, and only then drops to a customers-table lookup by dodoCustomerId (convex/payments/subscriptionHelpers.ts:682-689). So a signed wm_user_id tells you exactly which Clerk account was logged in at the moment of purchase, no matter what the buyer typed.

(c) Look up that Clerk user's login email — and search Clerk for every address in the ticket. Both halves matter. The first tells you where the account actually lives; the second proves whether the other addresses are accounts at all, or just inboxes. A ticket where the support-sender address, the checkout address, and the login address are three different aliases of one person is the signature of this failure mode.

(d) Query Convex entitlements for that userId (POST $CONVEX_SITE_URL/api/internal-entitlements with the shared secret). Check planKey, validUntil, and tier. This is the decision point.

(e) List Resend sends to see where the lifecycle email actually went, and read last_event. A clicked event on the checkout address is positive proof the buyer read the welcome mail — and therefore proof of which address they will try to sign in with.

The decision point

Entitlement for wm_user_idLogin email vs checkout emailVerdict
Correct plan and expiryDifferent addressesIdentity mix-up. Not a regression. Reply with the real login address.
Missing, wrong plan, or downgradedAnyReal pipeline defect. Escalate to the webhook/entitlement path.
CorrectSame addressLook elsewhere — Clerk sign-in method, deliverability, or a genuinely different account.

Do not skip step (d) because the ticket sounds urgent. "Entitlement is correct" is what converts a possible P0 into a reply-and-close, and it is one call.

Ops traps that make a healthy system look broken

Three separate false negatives, all encountered in this investigation:

Searching Resend for the login email finds nothing. It looks exactly like a send failure. It is not — the email was addressed to the checkout email, which is the whole bug. Always search Resend for every alias, not just the one the account uses.

The RESEND_API_KEY in .env.local is a restricted, send-only key. Any list/search call returns 401 restricted_api_key, which reads like an outage or a revoked key. Use RESEND_BROADCASTS_API_KEY to enumerate sent emails.

Dodo's API sits behind Cloudflare and rejects default client user-agents. A Python urllib default UA gets 403 with body error code: 1010, which looks like an auth failure against the billing provider during a billing incident. Set a browser User-Agent header.

One more environment note that costs time if unknown: the Clerk secret key lives in the Convex environment, not in .env.local. Looking for it locally and failing is not evidence that Clerk access is broken.

The design seam itself (fix pending)

Subscription lifecycle emails address the Dodo checkout email, never the account's login email. convex/payments/subscriptionHelpers.ts:1011:

ts
// Upsert customer record so portal session creation can find dodoCustomerId
const email = data.customer?.email ?? "";

That single value feeds both customer-facing sends — sendReactivationEmail at line 1046 and sendSubscriptionEmails at line 1061 — as userEmail, and it is also what gets written into the customers row (lines 1016-1031). The dunning and winback path inherits the same order of preference: getDunningContext in convex/payments/subscriptionEmails.ts:479-488 reads the subscription's rawPayload.customer.email first and only falls back to the customers row for the same userId. Every lifecycle email in the system therefore addresses the checkout inbox.

What lands in that inbox is an invitation to sign in. userWelcomeHtml renders "Your subscription is now active. Here's what's unlocked:" (convex/payments/subscriptionEmails.ts:197) above a CTA that for Pro plans reads "Open My Brief" and points at https://worldmonitor.app/brief (convex/payments/subscriptionEmails.ts:175-176, rendered at line 205) — an auth-gated route. A buyer who checked out under an alias reads "active," clicks through, types the address the email arrived at, and is told the account does not exist. From the buyer's seat that is indistinguishable from being scammed.

Filed as issue #6330 ("fix(payments): welcome email targets Dodo checkout email, steering alias buyers to a nonexistent login ("account not known")") — filed, unmerged as of this writing. Until it merges, expect this ticket class to recur, and diagnose it with the recipe above rather than re-deriving it.

A useful inference: the admin email is a receipt for the user email

sendSubscriptionEmails sends the user welcome first (convex/payments/subscriptionEmails.ts:326-333), then the admin notification (lines 345-359), as two sequential awaits in one action. The shared sendEmail helper throws on any non-OK Resend response (convex/payments/subscriptionEmails.ts:63-68). Therefore: if the admin "New User Subscribed" email arrived, the user welcome was accepted by Resend — a throw on the first send would have prevented the second from ever running. When triaging "the customer says they never got the welcome email," an admin notification in your own inbox already rules out a send failure and points you at addressing or deliverability instead.

Why This Matters

The expensive failure here is misdiagnosis, in both directions.

Treating it as a billing regression sends an engineer into the webhook and entitlement code for hours, where everything is correct and nothing explains the symptom. The known Dodo one-row-per-user multi-subscription hazard was explicitly checked in this case — the customer had a failed API Starter attempt three minutes before the successful Pro Business Yearly purchase, exactly the shape that could silently downgrade an entitlement — and it did not fire. The recompute-from-all-subs guard held. Confirming that took one entitlement query; guessing at it would have taken an afternoon.

Treating it as "user error" and replying with a password-reset link is equally wrong: there is nothing to reset, because there is no account at that address. The correct reply names the actual login address and the sign-in method.

And the customer-facing cost is disproportionate. This is the worst possible moment for a paying customer's first post-purchase experience to say "account not known" — they have just been charged several hundred francs by a company they do not yet trust, and the system's own welcome email is what steered them into the wall. Every hour this ticket class stays open is an hour a paying customer believes they were defrauded.

When to Apply

  • Any support ticket of the form "I paid but can't log in," "I paid but I don't have Pro," or "my account doesn't exist after payment."
  • Before opening a P0 on the entitlement pipeline off the back of a single customer report — run step (d) first.
  • When a customer says they never received a welcome, renewal, dunning, or winback email: check where it was actually addressed before assuming a send failure.
  • When any billing-provider record and any auth-provider record disagree about who a person is. The recipe generalizes past Dodo and Clerk: the durable move is to resolve identity from the signed metadata the checkout carried, never from a typed email.

Examples

The 2026-08-08 case, end to end, as the recipe runs:

(a) Dodo customer cus_0Nkt…, billing email webapps_wsr@… — an address that appears nowhere in the ticket's From header.

(b) Two subscriptions created 2026-08-07, both carrying wm_user_id=user_3HK… plus a valid wm_user_id_sig: API Starter at 20:17 UTC whose payment failed (card declined, €93.49), and Pro Business Yearly at 20:20 UTC whose payment succeeded (408.56 CHF), status active, next billing 2027-08-07. The signed metadata makes the "who bought" question answerable without touching an email at all.

(c) Clerk user_3HK… signs in with crypto_wsr@… via email code (password_enabled false, no OAuth). Searching Clerk for the checkout address and for the support-sender address returns nothing — neither is an account. Three aliases of one person, in three different roles: wsr2021@… sent the ticket, webapps_wsr@… bought the subscription, crypto_wsr@… owns the login.

(d) Convex entitlement for user_3HK…: planKey pro_business_annual, validUntil ≈ 2027-08-07, tier 1. Correct — and notably not downgraded by the failed API Starter attempt.

(e) Resend shows "Welcome to World Monitor Pro Business (Annual)" delivered 2026-08-07 20:21:05 to webapps_wsr@…, last_event=clicked (the Resend dashboard row is findable by recipient + subject + timestamp). The click is the missing link in the story: the buyer read the mail at the checkout alias, followed its sign-in CTA, and tried that alias at the login screen.

Verdict in one line: entitlement correct + login email ≠ checkout email → identity mix-up, not a regression. The reply names crypto_wsr@… as the login address and explains the email-code sign-in. The product fix is issue #6330.

  • Issue #6330 — welcome email targets the Dodo checkout email instead of the account's login email (open, unmerged as of 2026-08-08).
  • convex/payments/checkout.ts:201-202 — where the signed wm_user_id identity is stamped onto the checkout session.
  • convex/payments/subscriptionHelpers.ts:667-689tryResolveUserId trust order: HMAC-verified metadata, then customers lookup.
  • convex/payments/subscriptionHelpers.ts:1011 — the checkout email that feeds every customer-facing lifecycle send.
  • convex/payments/subscriptionEmails.ts:479-488getDunningContext, same recipient resolution for dunning and winback.
  • The Dodo one-row-per-user multi-subscription silent-downgrade hazard (checked here, did not fire) — see the entitlement-billing-gotchas skill.