When a signed-in user starts checkout, the backend opens a Lemon Squeezy checkout and sets custom_data.user_id to that account. The subscription_created webhook is what attaches the paid plan. The handler reads custom_data.user_id to know which account.
A subscription_created webhook arrived without user_id. The checkout had started from an authenticated session, so the field should have been there. It was not. One missing field on one event. That does not mean the provider always drops custom_data.
The refusal was intentional
I kept the handler from attaching a subscription when user_id is missing. A webhook that carries a payment and no owner is ambiguous. Attaching it anyway could put the paid plan on the wrong account.
The card was still charged. The subscription existed on the provider side, and the account stayed on the free plan.
What was added after
The refusal stays. If user_id is absent, the handler tries one recovery before it leaves the account path.
The rule, not the production source:
on subscription_created:
if custom_data.user_id is present:
attach to that account
else:
candidates = accounts whose email exactly matches the checkout email
if exactly one candidate, and the conflict guards pass:
attach to that candidate
else:
fall through to the guest path
The email has to match exactly, and it has to match one account. Two accounts on the same email, and the handler does not choose. Conflict guards run before anything is written.
If that step does not resolve, the subscription follows the guest path. It is not forced onto an account.
Retries
Webhooks get retried. Handling is idempotent, so a replayed subscription_created does not attach a second time, and it does not rewrite a state that is already settled.
A conflict is stored as subscription_created_conflict.
I build LiveSuggest, a meeting assistant that stays on the screen. No bot joins the call. This checkout is how a paid plan gets attached to an account.
Top comments (0)