WakaStart
Modules

Invitations API — Verification & Acceptance

The verify and accept endpoints: implementing the acceptance page, server-side effects and pitfalls.

Version v1.05 min read

Invitations API — Verification & Acceptance

This chapter covers the public verify and accept endpoints. Read Invitations — Creation first for context.


Endpoint — Verify a token (PUBLIC)

To be called from your acceptance page on load, before displaying the form.

http
GET /api/invitations/verify/{token}

Auth: None — public endpoint. Throttle: 10 req/min/IP.

Valid response (200):

json
{ "valid": true, "invitation": { "id": "uuid", "invitedEmail": "alice@acme.com", "invitedFirstName": "Alice", "invitedLastName": "Martin", "expiresAt": "2026-05-29T10:30:00.000Z", "customer": { "name": "Acme Corp" }, "profile": { "name": "Participant", "app": { "name": "MyApp" } }, "inviter": { "firstName": "Bob", "lastName": "Smith", "email": "bob@..." } } }

Invalid response (200 but valid: false):

json
{ "valid": false, "errorMessage": "Cette invitation a expiré" }

Possible errorMessage values:

  • "Invitation non trouvée"
  • "Cette invitation a déjà été acceptée"
  • "Cette invitation a expiré"
  • "Cette invitation a été annulée"

If the token is PENDING but expiresAt < now(), the API automatically marks the invitation as EXPIRED before responding.


React pattern — Acceptance page

typescript
// app/accept-invitation/[token]/page.tsx async function AcceptInvitationPage({ params }: { params: { token: string } }) { // Call the public-api DIRECTLY: the BFF controller (/api/proxy) requires an // authenticated admin across its entire surface, an anonymous invitee gets a 401 there. const res = await fetch(`https://api.wakastart.app/api/invitations/verify/${params.token}`); const { valid, invitation, errorMessage } = await res.json(); if (!valid) { return <InvitationErrorPage message={errorMessage} />; } return <AcceptInvitationForm invitation={invitation} token={params.token} />; }

Recommended error component:

typescript
function InvitationErrorPage({ message }: { message: string }) { const messages: Record<string, { title: string; action: string }> = { "Cette invitation a expiré": { title: "Ce lien a expiré", action: "Demandez un nouveau lien à l'administrateur.", }, "Cette invitation a déjà été acceptée": { title: "Compte déjà créé", action: "Connectez-vous directement avec votre email.", }, "Cette invitation a été annulée": { title: "Invitation annulée", action: "Contactez l'administrateur pour obtenir une nouvelle invitation.", }, "Invitation non trouvée": { title: "Lien invalide", action: "Ce lien d'invitation n'existe pas. Vérifiez l'URL.", }, }; const info = messages[message] ?? { title: "Erreur", action: "Contactez le support." }; return ( <div className="flex flex-col items-center gap-4 p-8"> <h1 className="text-2xl font-bold">{info.title}</h1> <p className="text-muted-foreground">{info.action}</p> </div> ); }

[!INFO] The error keys and messages above are returned by the API in French (see the errorMessage values listed earlier). Keep this lookup table keyed on those exact French strings — only the title/action values you display to your own users need to be translated on your side.


Endpoint — Accept an invitation (PUBLIC)

http
POST /api/invitations/accept/{token} Content-Type: application/json { "password": "MotDePasse2026!" }

Auth: None — public endpoint. Throttle: 5 req/min/IP.

201 Response:

json
{ "user": { "id": "uuid", "wid": "abc123", "email": "alice@acme.com", "firstName": "Alice", "lastName": "Martin", "customerId": "uuid", "profileId": "uuid", "adminLevel": "USER", "isActive": true, "keycloakId": "uuid-keycloak" }, "provisioning": { "status": "complete" }, "warnings": [] }

Fields like id, email, adminLevel, etc. are nested under user, not at the response root.

  • provisioning.status: complete if all 3 provisioning steps succeeded, partial if one of them failed (see below).
  • warnings: array of non-blocking warnings — populated when provisioning.status is partial, lets you identify which step failed.

Server-side effects:

  1. Creation of the Keycloak account in the Network's realm
  2. Creation of the user in the database (adminLevel: USER — forced; the userLevel field has been removed from the User model, it no longer exists)
  3. Creation of UserTeam entries for each teamAssignment
  4. The invitation is marked ACCEPTED
  5. A UserApp link (user × Customer × App) is created
  6. Sending of the Keycloak password setup email

These effects are best-effort, not 100% guaranteed. Steps 1, 5, and 6 (Keycloak account, UserApp link, reset email) are degradable: a failure on any of them does not block acceptance — the invitation is still marked ACCEPTED and the user is still created, but the response carries provisioning.status: "partial" and a matching entry in warnings[]. Exception: if INVITATION_EMAIL_STRICT=true server-side, an email-sending failure becomes blocking. Always inspect provisioning/warnings rather than assuming complete provisioning from the 201 status code alone.

Note: the password field in the body is documented but currently ignored by the service — Keycloak sends a separate password reset email.


curl example — full flow

bash
# 1. Your backend creates the invitation curl -X POST https://api.wakastart.app/api/invitations \ -H "x-api-key: sk_live_yourkey" \ -H "Content-Type: application/json" \ -d '{ "customerId": "11111111-1111-1111-1111-111111111111", "invitedEmail": "alice@acme.com", "profileId": "22222222-2222-2222-2222-222222222222", "language": "fr" }' # 2. The invitee clicks the link, your page verifies the token curl https://api.wakastart.app/api/invitations/verify/a3f8c2d1e4b7... # → 200 { valid: true, invitation: { invitedEmail, ... } } # 3. The invitee submits the form curl -X POST https://api.wakastart.app/api/invitations/accept/a3f8c2d1e4b7... \ -H "Content-Type: application/json" \ -d '{ "password": "MotDePasseSolide2026!" }' # → 201 { user: { ... }, provisioning: { status }, warnings: [] }

Best practices

  • Always call verify on mount before displaying the form — this saves the invitee from filling in a password for nothing.
  • Handle the 4 error states with explicit, appropriate messages.

Common pitfalls

  • Calling accept twice: after acceptance, the token is invalidated. The second call returns 400 Cette invitation a déjà été acceptée.
  • Forgetting the initial verify: the invitee fills in the form, the API responds 400 expired, poor UX.
  • adminLevel forced to USER: invitations cannot promote a user to admin. Admin rights are granted manually after acceptance. (The userLevel concept has been removed — it no longer gates anything on the invitations side.)
  • Throttle on accept: 5 req/min/IP. A bot attempting to brute-force tokens will be blocked after 5 attempts.

Go further