YuviUX — Product Designer
FixationsPayment Systems

Preventing Duplicate Identities in Payment-First Onboarding

Cashfree Payment Forms collect money but no verified identity. How AstroAnsh turns anonymous webhook payments into claimed seats, without ever minting an account from unverified data.

Published On 09 Aug 2026
Preventing Duplicate Identities in Payment-First Onboarding

Payment before person

On AstroAnsh, the payment usually arrives before the person does. Courses sell through a Cashfree Payment Form that circulates in WhatsApp groups: someone forwards the link, you pay on your phone, and the money lands in seconds.

What doesn't land is an identity. A payment form is not a signup form. Cashfree tells us an order was paid, for how much, and whatever phone number and email the payer typed into a text field. It verifies none of it.

That gap is where duplicate identities are born. A shared or mistyped number can silently merge two people into one account, or split one person into two. This is how we closed it.

Constraints

Why we couldn't just move checkout in-app

The new platform has its own checkout, and over time it will carry most payments. But the WhatsApp form is how this community already buys. The links keep circulating, trust in a new flow builds slowly, and shutting the form down would mean turning away paid students.

So both rails run in parallel, and the platform has to absorb payments it did not originate. Two constraints shape everything downstream. Cashfree Payment Forms expose no fetch or list API, so the webhook is the only real-time signal we get, with a CSV export as the manual fallback. And auth on the platform is phone plus OTP, which makes the phone number the identity key.

The trap

Creating accounts from webhooks

The obvious shortcut is to auto-create an account from the webhook payload. We treated that as the main thing to avoid, because the phone field on a payment form is just text somebody typed.

A buyer mistypes one digit and now owns an account she can never log into, because the OTP goes to a stranger's phone. A husband pays with his own number for his wife's seat. Two siblings pay from the same family phone and silently become one student. Someone who already has an account under one number pays with another and forks into two.

Every one of those writes a wrong identity into the database, and identity errors compound. Course progress, Kundli credits and certificates all hang off the account. Untangling them later means support tickets with no proof of who is who.

A payment proves that money moved, not who moved it

So the webhook never creates a user. It creates an unclaimed seat, and identity is attached later, once someone has proven the number is theirs.

The model

A seat, not a person

That reframe splits the system into two records with different grades of truth. pre_orders holds payment-grade facts: the order, the course, the plan, and whatever number was typed at checkout. users holds identity-grade facts: a phone number someone has proven control of via OTP. The claim flow is the bridge between them, and the phone number is the join key, but only after it is verified.

The webhook handler stays paranoid. It verifies the signature, persists the raw event before any parsing (a parsing bug should never lose a payment), then writes the pre-order. Payloads that can't be matched go to an unmatched_payments collection for a human, and a periodic CSV reconciliation from the Cashfree dashboard catches anything the webhook dropped.

webhook/pre-order.js
JavaScript
// pre_orders: one document per payment, written only by the webhook
{
  joinId: "9f2c...",           // UUID, travels through auth as ?join_id=
  phone: "7087362779",         // exactly as typed on the Cashfree form. Immutable.
  courseId: "august-batch-02",
  planName: "pro",
  cashfreeOrderId: "CF_...",
  status: "unclaimed",         // "unclaimed" | "claimed"
  claimedBy: null,             // user id once claimed
  claimedAt: null
}

The claim flow at /enrol

After paying, buyers are sent to /enrol. One screen, one field. What you bought sits up top, so the page opens by confirming the purchase exists. Below it, a phone input with a helper line that says the quiet part early: this number is how we find your purchase, and it can't be changed later.

Entering the number asks the server for the matching pre-order. The response is deliberately thin: whether a purchase exists, the course and plan, whether it has been claimed, and whether an account already exists for that number. Enough to route the person, nothing worth mining.

From there the flow branches. A returning student gets a warm sheet, their purchase card with "Payment received" and the amount, and a "Log in & enrol" button. A new buyer gets "Welcome to the AstroAnsh family" and "Sign up now". Both continue into the normal auth flow carrying ?join_id=, so nothing has to be re-entered on the other side.

The purchase card appears on both sheets for a reason. The first anxiety at this point is not the account. It is whether the money went through.

One rule sits under all of this: the number typed on /enrol only finds the seat. It proves nothing, and we never trust it. Proof happens in login or signup, where the person receives an OTP on that exact number. The claim inherits its identity from the verified session, never from the form.

The write

The claim is one conditional write

After OTP, attaching the seat to the account is a single atomic operation.

enrol/claim-seat.js
JavaScript
const res = await preOrders.findOneAndUpdate(
  { joinId, status: "unclaimed", phone: session.phone },  // phone from the OTP-verified session
  { $set: { status: "claimed", claimedBy: session.userId, claimedAt: new Date() } }
);
// no match means the seat was already claimed,
// or this session's number is not the one that paid

That filter is the whole security model. status: "unclaimed" means a seat transitions exactly once: two devices racing, a double tap, a re-forwarded /enrol link all resolve to one winner, and everyone else lands on "already claimed".

phone: session.phone means a seat can only attach to the identity that proved control of the paying number. There is no read-then-write gap to race and no client input to spoof. The database decides.

When the write succeeds, the seat has an owner, and the person sees the only screen in the flow that celebrates.

Designing the edges

Money makes edge states emotional, so each terminal state got a full screen with a way out, not an error toast.

No purchase found. The copy blames the mismatch, not the person, and offers "Try again" first, because the honest causes are ordinary: a typo now, a typo at checkout, or a webhook that hasn't landed yet for a seconds-old payment. Support sits right beside it for everything else.

Already claimed. The screen shows the date the seat was claimed and routes to login. The date matters. If you claimed it and forgot, it is a receipt. If you didn't, it is evidence, and "let us know" is the very next line.

Signed in as a different number. This is the duplicate-identity guard made visible. If you are logged in under one number and the purchase belongs to another, the screen shows both, plainly labelled "Paid with" and "Signed in as", and the only path forward is switching accounts. Silently attaching the seat to the current session is exactly the merge this system exists to prevent. The copy promises that switching loses no progress.

Not my phone number. The escape hatch for the hardest case: the number on file is genuinely wrong. It can't be edited on the client, because an editable join key reopens the original hole; anyone could redirect a paid seat to their own phone. So corrections go through a human.

The sheet asks you to message support on WhatsApp or email with the number you paid with and the correct one, and the team moves the enrolment across. It leads with "your payment is safe", because that is the fear it exists to answer.


One door

The same flow for two migrations

The same mechanic quietly solves a second problem: moving students off the previous app. Their existing enrolments load as unclaimed pre-orders, and they claim on the new platform by verifying their number, exactly like a WhatsApp-form buyer. One flow serves both populations: payments the platform didn't originate, and users it inherited.

What we'd keep

The pattern generalises well past astrology courses. When a payment rail can't verify identity, don't let it create identity. Quarantine its output as unclaimed records, make a person prove control of the join key, and make promotion out of quarantine a single conditional write.

Keep the join key immutable, and route corrections through a human. Then spend real design time on the failure screens, because in a paid flow that is where all the anxiety lives.

In-app checkout will close the gap at the source over time. Until the community fully moves, the claim flow keeps the habit they already trust, WhatsApp link and all, from ever turning two people into one account.