Invitations API
A person can be invited to two things:
- a people group (
kind: "group"), optionally carrying a seat on the inviter's subscription ("also add them to my account"), and - a seat on the inviter's subscription on its own (
kind: "seat"), sent from the Subscription page.
This API is the one place to list everything waiting for the signed-in person and to answer it. Nothing here needs a paid plan - there is no @RequireFeature on the controller, on purpose. A Free account can accept an invitation from a Family account; what a plan decides is what the person can then run (creating and sharing people groups stays gated by the user_groups feature, see the User Group API).
Endpoints
All endpoints require authentication (JWT or user API key).
| Method | Path | Purpose |
|---|---|---|
GET | /api/invitations/pending | Invitations waiting for the signed-in person to answer. |
POST | /api/invitations/:kind/:token/accept | Accept one. kind is group or seat. |
POST | /api/invitations/:kind/:token/decline | Decline one. |
GET | /api/seats/capacity | How many seats the signed-in person can still hand out on their own plan. |
POST | /api/seats/invite | Invite an email address to a seat (Subscription page). Body: email. |
POST | /api/seats/accept | Accept a seat by token. Body: token. (The unified endpoint above is preferred.) |
POST | /api/seats/decline | Decline a seat by token. Body: token. |
POST | /api/seats/resend/:id | Fresh token and expiry, and the invitation email again. |
DELETE | /api/seats/:id | Revoke a seat. Owner or billing admin. |
GET /api/invitations/pending
Returns an array, newest first. A group invitation and a seat invitation are listed separately - except that a seat that goes with a group invitation is never listed on its own: it is answered together with that invitation and shown as its seat field.
[
{
"kind": "group",
"token": "b3f0…64 hex chars",
"inviter": { "id": 7, "name": "Peter Cselotei" },
"message": "Welcome to the family calendar",
"createdAt": "2026-10-04T10:00:00.000Z",
"expiresAt": "2026-10-18T10:00:00.000Z",
"group": { "id": 41, "name": "Cselotei family" },
"seat": { "planName": "Family" }
}
]
groupis set forkind: "group";seatis set when a seat on the inviter's plan comes with the invitation (always forkind: "seat").seatisnullonce the held seat is no longer on offer (withdrawn, declined, expired).- Invitations are matched to the signed-in account: those addressed to the account (
targetUserId) and those addressed to its email address (a person invited before signing up finds the invitation waiting after registering with that address). The comparison is on the exact lower-cased address - never aLIKE, where an_in an address would be a wildcard and show one person another's invitation. - Expired invitations and invitations to a deleted group are left out. The inviter is exposed by display name only.
POST /api/invitations/:kind/:token/accept and /decline
{
"kind": "group",
"accepted": true,
"group": { "id": 41, "name": "Cselotei family" },
"seatActivated": true
}
groupis the group joined (nullfor a seat, and for a decline).seatActivatedistruewhen accepting also put the person on the inviter's plan. A group invitation whose seat has gone (withdrawn or expired meanwhile) still joins the group and reportsseatActivated: false.- Accepting a seat refreshes the person's entitlement cache at once (
SubscriptionEntitlementService.invalidate) - the plan's features apply on the next request, not after the 15-minute cache. :kindis validated (ParseEnumPipe); anything else is a 400.
Errors
Errors are the usual { error: { code, message } } envelope; the code is stable and is what a client turns into a sentence in the person's language.
| Status | error.code | Meaning |
|---|---|---|
| 404 | INVITATION_NOT_FOUND | No such pending invitation: already answered, withdrawn, or a wrong token. |
| 404 / 400 | INVITATION_EXPIRED | It ran out. (Group invitations answer 404; seats 400.) |
| 403 | INVITATION_NOT_FOR_YOU | The signed-in account's email is not the invited address, or the invitation is addressed to a different account. |
Holding the token is not enough to answer: the account must be the one the invitation was written for. Without that, a forwarded email would hand a seat to whoever opened it. Accounts are matched by email address, and accounts are not email-verified, so the trust model is email equality - the same as for group invitations before this API existed.
Seat capacity
GET /api/seats/capacity:
{ "canInvite": true, "seatLimit": 5, "used": 1, "remaining": 4, "planName": "Family" }
canInviteis false for a plan that is only its owner (a seat limit of 1) - nothing to give.seatLimitandremainingarenullfor an unlimited plan. The limit is the plan's seats (or the admin override), plus seats granted by the owner's active add-ons - the same formulaPOST /api/seats/inviteenforces.usedcounts seats held: pending, accepted and active. A declined, expired or revoked seat is free again.
The People drawer reads this to offer Also add them to my account.
Seat statuses
SeatStatus: pending, accepted, active, revoked, expired, and declined (the person said no). The status column is a varchar, so declined needed no schema change; the audit log gained SEAT_DECLINED.
Seat errors
| Status | error.code | Meaning |
|---|---|---|
| 403 | SEAT_MANAGEMENT_NOT_AVAILABLE | The plan has no seats to give. |
| 400 | SEAT_LIMIT_REACHED | Every seat is in use or promised. |
| 409 | SEAT_ALREADY_ASSIGNED | That address already has a seat or a pending invitation on this subscription. |
| 400 | CANNOT_INVITE_OWNER | The address is the owner's own. |
| 400 | SEAT_BELONGS_TO_GROUP_INVITE | resend on a seat that is held by a people-group invitation: resend (or withdraw) that invitation instead. |
Emails are normalized (trimmed, lower-cased) when a seat is created, so addresses match however they were typed.
The invitation email
Both flows send the same transactional email straight to the address - not through the notification pipeline. Reasons: the recipient may have no account yet, and an invitation is not something a notification preference should be able to switch off. The in-app notification (for an address with an account) is published with preferredChannels: IN_APP_ONLY, so the inbox row is all the notification pipeline does for it: a user who enabled the email channel for the event type does not get a second, link-less email, and creating the invitation never waits on notification delivery. An invitation therefore raises no push notification either; the email and the first-open popup are how a person is told.
- Content: subject, plain-text part and an HTML part, in the recipient's language when they have an account, else the inviter's, else English (
en,hu,de,fr). The copy lives ini18n/<lang>/email.jsonunderinvitation.*; the layout istemplates/emails/<lang>/invitation.hbs. Everything a person typed (inviter name, group name, note) is HTML-escaped before it reaches the template, which substitutes raw. - Links go to the app's Invitations page and never answer by themselves:
…/app/invitations?kind=group&token=…&action=accept(andaction=decline). The page shows the invitation with that answer ready; it takes one more tap. For an address with no account the button is…/app?register=1&email=…&returnUrl=…, which opens sign-up with the address filled in. An address with no account gets no decline link (ignoring the email is the answer). - Delivery:
sendInvitationreturns whether the mail went out and never throws - an unconfigured or failing provider leaves the invitation saved (emailSendAttemptsis counted,emailSentAtset on success) and the invite response saysemailSent: false. - Interpolation: nestjs-i18n formats with
string-format, where{{x}}is a literal{x}; the invitation strings use single braces ({inviter}). The older{{title}}-style strings inemail.jsonandnotifications.jsondo not interpolate.
First-open popup (frontend contract)
AppLayout mounts PendingInvitationsPrompt (after its Kid-mode early return, so a child's shell never shows it). It calls GET /api/invitations/pending when the signed-in person is anywhere inside /app except /app/invitations and the full-bleed present view, and opens a modal with every invitation and Accept / Decline. Decide later (or Esc) is remembered for the browser tab in sessionStorage under keys that carry no token (kind:inviterId:createdAt); a new tab asks again, and an invitation that arrives later is asked about even in the same tab.
An accepted answer invalidates the invitations, groups and calendar queries and, when seatActivated, broadcasts primecal:entitlements-changed so every mounted useEntitlements() reads the new plan features.
Related
- User Group API - creating invitations,
addToMyAccount, withdrawing, leaving - Notifications API - the in-app notification and its
data.deepLink - Answering an Invitation - the end-user guide