The invite that can't be an oracle
Every growth loop that reaches strangers by email is one bad design decision away from being a nuisance — or worse, a tool. Paste a list of addresses at a naive "invite a friend" endpoint and watch which ones come back different, and you've turned a growth feature into a user directory oracle: a way to learn who's a member and who isn't, one address at a time. Ship the send path first and the opt-out list later and you've promised, in the gap between those two deploys, to email people who already said no.
So we built the invite loop the other way around. The rule we held to: a growth loop you'd be comfortable explaining to every recipient is one where the abuse cases were designed out before the send path existed. Here's what that looked like in practice.
The opt-out list shipped before the send button
The first slice of the loop wasn't sending. It was not sending.
The suppression list — the "never email this person again, no matter who asks" table — landed in the very same schema change as the invites table itself, before a single line of code could put an email on the wire. Compliance machinery usually arrives as a patch after the first complaint. Putting the suppression list in the ground first, and keying it on the contact (not the inviter), makes the platform-wide opt-out an invariant of the schema rather than a behavior some later code path has to remember to check. There is no way to send that doesn't already have somewhere to look up "has this person opted out of everything."
Compliance-as-the-first-slice is a small reordering with a big property: at no point in the project's history was there a version that could send but couldn't suppress.
The endpoint that returns the same "ok" every time
Then the send path. The request to invite an address returns the exact same
status: "ok" whether the email was sent, was already on the suppression list,
had hit its lifetime cap, was malformed, or belongs to someone who's already
a member. From the outside, all of those are indistinguishable. You cannot
paste addresses at it to learn who uses Vibes or who unsubscribed, because the
response carries no signal to read.
The one thing that is distinguishable is rate-limited — but that's the
inviter's own daily quota talking, which is the inviter's own information and
leaks nothing about any contact. Everything that could reveal something about
the recipient collapses to a single neutral answer.
To be precise about the guarantee: this is uniformity of the response — the status and body are byte-for-byte identical whichever branch runs. It isn't timing uniformity. The branches do different amounts of database work, so a determined attacker with a timing side-channel could still tease some cases apart; closing that would take constant-work handling, not a different response shape, and it wasn't a goal for v1. What the design does guarantee is that the reply never answers "is this address a member."
Get posts like this in your inbox
One email field. Real updates. No algorithm required.
A couple of details fell out of that stance that we liked enough to keep:
- The per-contact lifetime cap counts both the plain address and its hashed form, so you can't wait out the expiry window — which deliberately hashes the address away to purge the raw PII — and use that as a way to reset the cap and start over. Suppression outlives the PII.
- Because the endpoint's whole job is to not differentiate, the code that keeps it honest is boring on purpose. There's nothing clever to get subtly wrong, which is the point.
Redemption carries two different amounts of consent
Say the invite lands and the person acts on it. There are two ways they can end up connected to the inviter, and they don't carry the same weight, so they don't resolve the same way.
Clicking the invite link — token in hand, signed in — is an explicit, bilateral "yes." The invite is the approval, so both follow edges go straight to active, skipping the "requested" state a private account would normally impose. You asked; they clicked; done.
Merely signing up with an address that happens to have pending invites waiting is a weaker signal. That person never clicked that invite — they just showed up. So it resolves to a one-directional follow (the inviter follows the new member), which the new member is free to reciprocate, or not. Same raw event, less consent, a more cautious outcome.
The hinge that makes both safe is a single atomic flip. Redemption is a guarded
pending → redeemed update, and that update is the serialization point: a
frantic double-click and a race against an about-to-expire invite both resolve
to exactly one winner and one loser. No invite gets spent twice, and no expired
row can wedge things — redemption hashes the contact key the same collision-safe
way the expiry sweep does, so a stale row can't collide with a fresh one.
An app can ask to invite — the platform gets consent
The most interesting version of this is inside the sandbox. We wanted a vibe to be able to invite your friends — but the obvious implementation, a "send" button the app controls, is exactly the thing you don't want an arbitrary sandboxed app holding.
So the app doesn't send. It asks. A vibe calls useSocial().invite(email),
which posts a request across the iframe bridge; the platform chrome — not the
app — pops a modal that names the address and asks you to confirm; and only your
click actually sends anything. Three properties make that trustworthy:
- Fail-closed. If no consent gate is wired up — a server path, a test harness, anything that isn't a real user looking at a real modal — the handler sends nothing. A real outbound email is precisely the thing you never want firing by default.
- The app can supply the address but not its own name. The contact email is legitimately whatever the app collected. But the app identity shown on the consent card comes from the routed URL, never from the message the iframe sent — otherwise a forged postMessage could dress App A's invite up as App B's.
- Enumeration-safe end to end. The neutral "ok" holds all the way through the sandbox path too, so even a transport error reports the same thing rather than leaking whether the address is already a member.
It's the same shape as asking an app for permission to post to your audience: the app asks, the platform mediates, the human consents. That "app asks, platform grants" pattern is quietly becoming the house style for anything a sandboxed vibe wants to do out in the world — the same social graph the follow features run on, reached through a consent seam instead of a raw capability.
No secret to forget
There's a version of this feature that ships and then sits dark for weeks, and it's worth saying why this one didn't. Unsubscribe links usually authenticate with a signed token — an HMAC over a server secret — which means the loop can't safely send until that secret is provisioned onto the worker that verifies it. Miss that step and you have perfect, merged, deployed code that sends nothing.
The invite loop sidesteps the ceremony. Its unsubscribe link carries the invite's own stored, opaque token, and possession of that token — delivered only to the contact's inbox — is the proof. No signing secret, nothing to provision, no verifying worker that goes quiet waiting for a key. There's no scheduled scan to arm, either: a successful insert of the pending invite enqueues the email in the same step, so "the row exists" and "the email is on its way" are one event, not two separated by a cron.
The unglamorous parts are still there and still load-bearing — a one-click, CAN-SPAM-compliant opt-out on every email, and a suppression that's permanent and spans every inviter, not just the one who sent this note. But by making the token itself the credential, the design keeps its own last mile short: there's no separate secret to forget, so there's no gap between "shipped" and "live" to fall into.
The point
None of these pieces is exotic on its own. What made the loop something we'd stand behind is the order: suppression before sending, a neutral response before an informative one, consent decided by the platform before the app ever touches the wire. You can bolt privacy onto a growth feature after launch, and plenty of products do. It's cheaper — and far more honest — to make the feature's shape carry the guarantee, so that "explain it to every recipient" is a description of what it does, not an apology for it.
Build apps that respect the people they reach
The social graph, the consent seams, and the sandbox are all yours to build on.
Start building →