Invitations API — Verification & Acceptance
The verify and accept endpoints: implementing the acceptance page, server-side effects and pitfalls.
Invitations API — Verification & Acceptance
This chapter covers the public
verifyandacceptendpoints. Read Invitations — Creation first for context.
Endpoint — Verify a token (PUBLIC)
To be called from your acceptance page on load, before displaying the form.
httpGET /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
PENDINGbutexpiresAt < now(), the API automatically marks the invitation asEXPIREDbefore 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:
typescriptfunction 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
errorMessagevalues listed earlier). Keep this lookup table keyed on those exact French strings — only thetitle/actionvalues you display to your own users need to be translated on your side.
Endpoint — Accept an invitation (PUBLIC)
httpPOST /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:completeif all 3 provisioning steps succeeded,partialif one of them failed (see below).warnings: array of non-blocking warnings — populated whenprovisioning.statusispartial, lets you identify which step failed.
Server-side effects:
- Creation of the Keycloak account in the Network's realm
- Creation of the user in the database (
adminLevel: USER— forced; theuserLevelfield has been removed from theUsermodel, it no longer exists) - Creation of
UserTeamentries for eachteamAssignment - The invitation is marked
ACCEPTED - A UserApp link (user × Customer × App) is created
- 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
ACCEPTEDand the user is still created, but the response carriesprovisioning.status: "partial"and a matching entry inwarnings[]. Exception: ifINVITATION_EMAIL_STRICT=trueserver-side, an email-sending failure becomes blocking. Always inspectprovisioning/warningsrather than assuming complete provisioning from the 201 status code alone.Note: the
passwordfield 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
verifyon 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
accepttwice: after acceptance, the token is invalidated. The second call returns400 Cette invitation a déjà été acceptée. - Forgetting the initial
verify: the invitee fills in the form, the API responds400 expired, poor UX. adminLevelforced toUSER: invitations cannot promote a user to admin. Admin rights are granted manually after acceptance. (TheuserLevelconcept 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
- Invitations — Creation: POST endpoint, prerequisites, lifecycle
- Business errors: handling
valid: falsemessages - Access control:
invitations.createright and feature cascade