Team Invite Email Links (2026): Onboard Collaborators with Rovela
Create a single-use invite token in your app and send it with POST /emails—the same link pattern as password reset, with click tracking off.
Invite links for multi-user apps
If you are building an AI product, a collaborative workspace, or any SaaS where more than one person shares a project, you eventually need a team invite: an admin enters an email, your app sends a one-time accept link, the invitee lands on your origin, and membership (plus role) is created after redeem.
Rovela does not provide a dedicated “team invite email” API or product feature. Docs that mention “accepting an invitation” refer to Rovela dashboard organization membership — inviting colleagues into your Rovela org, not sending invites to end users of your application. For product invites, you own the token and the accept route; Rovela only delivers the message through POST /emails.
The closest official recipe is password reset links: 32 random bytes, store a hash, short expiry, single-use redeem, html + text with the full URL. Magic login email links is the same send shape with a different landing page. Team invites add team id, role, and an optional inviter — still one HTTP send.
What you build vs what Rovela delivers
Your application owns:
- Invite records: email, team/workspace id, role, inviter, status (
pending/accepted/revoked) - Cryptographic token generation; store only a hash; expiry; single-use redeem on your origin
- Absolute accept URL (
https://app.example.com/invites/accept?token=…) - Auth on accept (create account or bind to existing session) and membership write
- Resend / revoke / rate limits; server-side API calls only
Rovela delivers:
POST https://api.rovela.dev/emailswith a Bearerre_…key (sending_accessis enough for send)- Queueing, DKIM for a verified domain, retries, bounce/complaint handling, message log
- Optional templates, webhooks, suppressions — same stack as any transactional mail
Do not put API keys in browsers or mobile clients. Plans and volume live on pricing; this article invents none of those numbers.
Token + store (hash, team/role, expiry)
Follow the entropy rules from the password-reset example:
- Normalize the invitee email (
trim+ lowercase). - Authorize the inviter (they must be allowed to add members to that team).
- Generate 32 random bytes, encode as
base64url. - Store only the SHA-256 hex of the token — never the raw token. Thirty-two bytes are not brute-forceable from the hash, so plain SHA-256 is enough (unlike six-digit OTPs, which need HMAC with a pepper — see verification codes).
- Persist
token_hash,team_id,role,email,invited_by,expires_at(often 7 days for invites; pick what fits your product),status=pending. - Build
https://app.acme.com/invites/accept?token=+ the raw token. The token appears only in the email.
A new invite for the same email+team should invalidate or supersede the previous pending row so two live links cannot disagree about role.
Unlike password reset or magic login, invite flows usually do reveal that an invite was created for that address (the admin just typed it). Still avoid leaking whether the invitee already has an Acme account in the invitee-facing error pages after they click.
Send with POST /emails
Use a verified subdomain sender for auth/transactional reputation (for example invites@mail.acme.com or security@mail.acme.com). Send both html and text, and put the full URL in the plain-text body — corporate filters sometimes strip HTML links.
Set an Idempotency-Key once per logical send (≤256 characters, 24-hour window, scoped to the organization). Derive it from a stable prefix of the token hash so a timeout retry does not mint a second email. Admin “resend invite” is a new logical send: new token, new key.
Sketch adapted from the password-reset docs (placeholders only):
import crypto from "node:crypto"
export async function createTeamInvite({ teamId, email, role, invitedBy }) {
const normalized = email.trim().toLowerCase()
const token = crypto.randomBytes(32).toString("base64url")
const tokenHash = crypto.createHash("sha256").update(token).digest("hex")
await db.teamInvites.insert({
teamId,
email: normalized,
role,
invitedBy,
tokenHash,
status: "pending",
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
})
const link = "https://app.acme.com/invites/accept?token=" + token
const res = await fetch("https://api.rovela.dev/emails", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.ROVELA_API_KEY,
"Content-Type": "application/json",
"Idempotency-Key": "invite-" + tokenHash.slice(0, 32),
},
body: JSON.stringify({
from: "Acme <invites@mail.acme.com>",
to: normalized,
subject: "You are invited to join an Acme team",
html:
"<p>You have been invited to collaborate on Acme.</p>" +
'<p><a href="' + link + '">Accept invitation</a></p>' +
"<p>This link works once and expires in 7 days. " +
"If you were not expecting it, you can ignore this email.</p>",
text:
"You have been invited to collaborate on Acme.\n\n" +
"Accept invitation: " + link + "\n\n" +
"This link works once and expires in 7 days.",
}),
})
// 200 = accepted/queued, not delivered
return res.json()
}
A 200 with {"object":"email","id":"…"} means Rovela queued the message. Wire webhooks if you need bounce/complaint signals. Full field reference: Send Email API. You can also send via a template with an {{invite_url}} variable — escape non-URL user fields; template values are inserted without HTML escaping.
Accept the invite once (session + membership)
Spend the token in one database step so parallel clicks cannot both succeed:
export async function acceptTeamInvite(token, currentUserId) {
const tokenHash = crypto.createHash("sha256").update(token).digest("hex")
const row = await db.query(
`UPDATE team_invites
SET status = 'accepted', accepted_at = NOW()
WHERE token_hash = $1
AND status = 'pending'
AND expires_at > NOW()
RETURNING team_id, role, email`,
[tokenHash]
)
if (row.rowCount === 0) return null
const invite = row.rows[0]
// Bind membership to the signed-in user; enforce email match if required
await db.memberships.upsert({
teamId: invite.team_id,
userId: currentUserId,
role: invite.role,
})
return invite
}
Typical product rules on the accept page:
- If the visitor is logged out, show sign-up / magic-login / password login first, then redeem.
- Optionally require that
currentUser.emailmatchesinvite.email. - After success, redirect into the team; do not leave the raw token in the address bar longer than needed (consume then redirect without query string).
This is your membership model. It is unrelated to inviting someone into a Rovela dashboard org.
Tracking off + text fallback
Open and click tracking are per-domain settings. When click tracking is on, Rovela rewrites links through its tracking host. The invite URL still works after the redirect, but the token takes an extra hop.
For security mail — invites, password resets, magic login — prefer a separate sending domain or subdomain with click tracking off, so the token goes straight to your app. Engagement webhooks for opens/clicks will not fire on that domain; that is expected.
Always keep the plain-text part with the full URL. HTML-only bodies lose to filters that strip anchors.
Resends, revocation, suppression
- Resend: invalidate the previous pending token (or mark superseded), mint a new 32-byte token, new
Idempotency-Key, send again. Rate-limit per team+email. - Revoke: set
status=revoked(or delete the hash). Redeem must check status so a forwarded old link fails closed. - Role change before accept: either revoke+reinvite, or update the pending row and resend so the email copy matches the role you will grant.
- Suppressed recipient: for a single
POST /emails, the verification-code docs document a400whose message starts withrecipient is suppressed. Treat as undeliverable — ask the admin for another address; do not tell the invitee to “check inbox.” - Unverified
fromdomain:400naming the domain — finish domain verification first. 429: back off; retry the same logical send with the sameIdempotency-Key. Intentional resend = new token + new key.
Avoid cc/bcc on invite mail (token leak). Prefer one recipient per send.
Related patterns
| Flow | Who owns the secret | Docs / article |
|---|---|---|
| Team invite link | Your app (token + team/role) | This article; send via POST /emails |
| Magic login | Your app (token → session) | Magic login email links |
| Password reset | Your app (token → new password) | Password reset links |
| Email OTP | Your app (code hash) | Verification codes |
| Rovela org invite | Rovela dashboard session | Authentication — not an end-user invite API |
Ship the invite table yourself, send with one HTTP call, keep tracking off on the security domain, and you can onboard collaborators without treating the email API as an identity or directory product.