Skip to main content
Was this helpful?

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).

MethodPathPurpose
GET/api/invitations/pendingInvitations waiting for the signed-in person to answer.
POST/api/invitations/:kind/:token/acceptAccept one. kind is group or seat.
POST/api/invitations/:kind/:token/declineDecline one.
GET/api/seats/capacityHow many seats the signed-in person can still hand out on their own plan.
POST/api/seats/inviteInvite an email address to a seat (Subscription page). Body: email.
POST/api/seats/acceptAccept a seat by token. Body: token. (The unified endpoint above is preferred.)
POST/api/seats/declineDecline a seat by token. Body: token.
POST/api/seats/resend/:idFresh token and expiry, and the invitation email again.
DELETE/api/seats/:idRevoke 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" }
}
]
  • group is set for kind: "group"; seat is set when a seat on the inviter's plan comes with the invitation (always for kind: "seat"). seat is null once 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 a LIKE, 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
}
  • group is the group joined (null for a seat, and for a decline).
  • seatActivated is true when 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 reports seatActivated: 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.
  • :kind is 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.

Statuserror.codeMeaning
404INVITATION_NOT_FOUNDNo such pending invitation: already answered, withdrawn, or a wrong token.
404 / 400INVITATION_EXPIREDIt ran out. (Group invitations answer 404; seats 400.)
403INVITATION_NOT_FOR_YOUThe 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" }
  • canInvite is false for a plan that is only its owner (a seat limit of 1) - nothing to give.
  • seatLimit and remaining are null for 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 formula POST /api/seats/invite enforces.
  • used counts 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​

Statuserror.codeMeaning
403SEAT_MANAGEMENT_NOT_AVAILABLEThe plan has no seats to give.
400SEAT_LIMIT_REACHEDEvery seat is in use or promised.
409SEAT_ALREADY_ASSIGNEDThat address already has a seat or a pending invitation on this subscription.
400CANNOT_INVITE_OWNERThe address is the owner's own.
400SEAT_BELONGS_TO_GROUP_INVITEresend 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 in i18n/<lang>/email.json under invitation.*; the layout is templates/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 (and action=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: sendInvitation returns whether the mail went out and never throws - an unconfigured or failing provider leaves the invitation saved (emailSendAttempts is counted, emailSentAt set on success) and the invite response says emailSent: 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 in email.json and notifications.json do 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.