Skip to content

Invitations HTTP API

This is the reference for the /v1/domains/{id}/invitations HTTP surface. It maps each operation to its OpenAPI schema, ReBAC gate, audit emission, outbox event, and the closed Problem.code taxonomy. The surface ships four public operations — CreateInvitation, ListInvitations, GetInvitation, and RevokeInvitation — plus an internal JIT acceptance fork the OIDC sign-in callback consumes to flip a pending Invitation to accepted atomically with the User upsert (no public route — see ../../contexts/identity/idp.md#jit-acceptance-fork). The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract — for the bounded- context narrative covering the lifecycle states (pending → accepted / revoked / expired), the partial-unique pending invariant, and the per-Domain pseudonym contract see ../../contexts/identity/invitations.md.

Operations

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
POST/v1/domains/{id}/invitationsCreateInvitationdomain:<id>#manageinvitation.createInvitationCreated8 KiB
GET/v1/domains/{id}/invitationsListInvitationsdomain:<id>#readinvitation.list (page-level row carrying item_count + status + authz_errors)(none)n/a
GET/v1/domains/{id}/invitations/{invitationId}GetInvitationdomain:<id>#readinvitation.read(none)n/a
DELETE/v1/domains/{id}/invitations/{invitationId}RevokeInvitationdomain:<id>#manageinvitation.revokeInvitationRevokedn/a
(internal)OIDC sign-in callbackAcceptDuringSignIn(callback already authenticated; no public route)invitation.acceptInvitationAccepted (carries the denormalised tuple_objects array; the relation-tuples projector consumes the same event and writes one SpiceDB tuple per entry)n/a
  • body_cap = 8 KiB references the MaxInvitationRequestBodyBytes-style enforcement applied before the JSON decoder runs; an over-cap body surfaces as 413 request_body_too_large.
  • ListInvitations.limit query parameter is clamped at the handler to [1, 200] with default 50.
  • ListInvitations.cursor is opaque, HMAC-signed by the server; a tampered cursor surfaces as 400 invalid_cursor.
  • GetInvitation and RevokeInvitation collapse a cross-Domain match (a row that exists, but in a different Domain than the path Domain) into the same 404 invitation_not_found posture as a truly missing row so neither endpoint can be used as a cross-Domain enumeration oracle.
  • RevokeInvitation is idempotent on the already-revoked terminal state — a second revoke returns 204 No Content without invoking the mutator. The other terminal states surface as 409 invitation_already_accepted / 409 invitation_already_expired.
  • The internal JIT acceptance fork is consumed by the OIDC sign-in callback — there is no public HTTP route. The aggregate flips pending → accepted inside the same transaction as the User upsert, and the outbox row chain (a single InvitationAccepted row carrying the denormalised tuple_objects array) lands atomically with the UserCreated / UserSignedIn events. The downstream relation-tuples projector consumes that same event (no per-tuple outbox row exists) and writes one SpiceDB tuple per tuple_objects entry.

Path & query parameters

OperationParameterTypeRequiredNotes
Allid (path)string (uuid)yesDomain UUIDv7. Non-zero. Malformed → 400 invalid_domain_id.
GetInvitation / RevokeInvitationinvitationId (path)string (uuid)yesInvitation UUIDv7. Non-zero. Malformed → 400 invalid_invitation_id.
ListInvitationsstatus (query)stringnoClosed set {pending, accepted, revoked, expired, all}, default all. Out-of-set → 400 invalid_status.
ListInvitationslimit (query)integerno[1, 200], default 50. Out-of-range → 400 invalid_limit.
ListInvitationscursor (query)stringnoOpaque HMAC-signed continuation. Tampered or malformed → 400 invalid_cursor.

Schemas

InvitationCreateRequest

Body for POST /v1/domains/{id}/invitations. The handler authorises the call against the parent Domain's manage ReBAC relation BEFORE invoking the service so an unauthorised caller never produces an InvitationCreated outbox row. The plaintext external_subject is intentionally NOT echoed in the response — only the per-Domain pseudonym is — so a server-side log of the response body never carries raw IdP-side PII.

FieldTypeRequiredNotes
external_subjectstringyesminLength=1, maxLength=255. IdP-side subject identifier (typically the OIDC sub claim or operator-known login name). Whitespace-only is rejected by the aggregate.
ttl_secondsintegernominimum=60, maximum=604800, default=86400. Lifetime in seconds applied to created_at to derive expires_at. Out-of-range → 400 invalid_ttl.
initial_tuplesarray<InitialTuple>nomaxItems=32. Optional bounded list of relation tuples to write atomically when the invitation is accepted. Exceeding the cap → 422 too_many_initial_tuples.

InvitationResponse

Hydrated projection of an Invitation aggregate. The shape is shared by CreateInvitation, GetInvitation, and ListInvitations. Plaintext external_subject is intentionally absent — the wire shape only carries the per-Domain pseudonym so a read-only caller never observes raw IdP-side PII. Lifecycle-stamp pointer fields are populated only when the underlying aggregate carries the matching timestamp; encoders elide them under the omitempty contract.

FieldTypeRequiredNotes
idstring (uuid)yesInvitation identifier (UUIDv7).
domain_idstring (uuid)yesOwning Domain identifier (UUIDv7).
external_subject_pseudonymstring (^[0-9a-f]{64}$)yesPer-Domain pseudonym of the IdP-side external_subject (32 bytes, lowercase hex). The plaintext form is never echoed on the invitation read surface.
statusInvitationStatusyesClosed-set lifecycle status.
expires_atstring (date-time)yesWall-clock timestamp the invitation transitions to expired if no Accept / Revoke fires. Derived from created_at + ttl_seconds.
created_atstring (date-time)yesAggregate creation timestamp (UTC).
accepted_atstring (date-time) (nullable)noPopulated only when status == accepted.
accepted_user_idstring (uuid) (nullable)noIdentifier of the User aggregate the OIDC sign-in callback resolved when accepting the invitation. Populated only when status == accepted.
revoked_atstring (date-time) (nullable)noPopulated only when status == revoked.
expired_atstring (date-time) (nullable)noPopulated only when status == expired.
initial_tuplesarray<InitialTuple>nomaxItems=32. Bounded list of relation tuples staged on the invitation, returned verbatim from the persistence layer so the operator can preview which tuples will land on Accept.

InvitationList

Page of invitations returned by GET /v1/domains/{id}/invitations. The window is computed by the persistence layer in (created_at DESC, id DESC) order; per-row visibility is layered on top so the items array is the subset the caller is authorised to see.

FieldTypeRequiredNotes
itemsarray<InvitationResponse>yesInvitations in the current page.
next_cursorstring (nullable)noOpaque HMAC-signed continuation. Absent or null at end-of-stream.

InitialTuple

Relation tuple consumed atomically when the Invitation is accepted. The aggregate stores the tuples as a bounded jsonb array on plexsphere.invitations.initial_tuples. The handler validates the prefix BEFORE the aggregate constructor so an out-of-scope object surfaces as 422 invitation_object_out_of_scope rather than a generic invalid_body.

FieldTypeRequiredNotes
relationstringyesminLength=1. SpiceDB relation the tuple grants on object (e.g. member, viewer). Whitespace-only is rejected by the aggregate.
objectstringyesminLength=1. SpiceDB object reference. Allowed prefix set is closed: domain:<this-domain-id> / project:<uuid> / group:<uuid>. Cross-Domain or platform-scoped objects → 422 invitation_object_out_of_scope.
caveat_contextobjectnoOptional CEL caveat context forwarded verbatim to SpiceDB on accept. The aggregate enforces JSON-encodable lossless round-trip — a value that fails the round-trip → 422 invalid_caveat_context.

InvitationStatus

Closed-set lifecycle status enum. pending is the initial state; accepted, revoked, and expired are absorbing terminal states. The four values mirror the plexsphere.invitations.status CHECK constraint and the aggregate's Status enum.

ValueMeaning
pendingInitial state. The row holds the partial-unique pending slot for (domain_id, external_subject) until it transitions to a terminal state.
acceptedTerminal. Reached via the JIT acceptance fork during OIDC sign-in. Pairs with accepted_at + accepted_user_id.
revokedTerminal. Reached via DELETE /v1/domains/{id}/invitations/{invitationId}. Pairs with revoked_at.
expiredTerminal. Reached via the platform-scheduled expiry sweeper that bulk-stamps elapsed pending rows. Pairs with expired_at.

Initial-tuple validation

The handler runs three orthogonal checks against the request body's initial_tuples array before the service is invoked. Every rejection surfaces with a precise Problem.code so the operator can branch on the offending invariant.

InvariantCap / ruleProblem codeHTTP
Lengthlen(initial_tuples) <= 32too_many_initial_tuples422
Object scopeobject MUST start with domain:<this-id> (the path Domain), project:<uuid>, or group:<uuid>; cross-Domain or platform objects are rejectedinvitation_object_out_of_scope422
Caveat shapecaveat_context MUST round-trip through JSON encode → decode without losing informationinvalid_caveat_context422

The aggregate constructor enforces the same invariants; the handler- side pre-check exists so the wire taxonomy stays bounded — without the pre-check a 33-entry list would surface as a generic invalid_body rather than the precise too_many_initial_tuples.

ReBAC contract

OperationRelation evaluatedSubjectObjectOn denial
CreateInvitationmanage (BEFORE service.Create)resolved principaldomain:<id>403 PermissionDenied + audit row relation=invitation.create, outcome=permission_denied. NO InvitationCreated outbox row.
ListInvitationsread (BEFORE persistence read)resolved principaldomain:<id>403 PermissionDenied + audit row relation=invitation.list, outcome=permission_denied. Existence side-channel closed.
GetInvitationread (BEFORE persistence read)resolved principaldomain:<id>403 PermissionDenied + audit row relation=invitation.read, outcome=permission_denied. Existence side-channel closed.
RevokeInvitationmanage (BEFORE service.Revoke)resolved principaldomain:<id>403 PermissionDenied + audit row relation=invitation.revoke, outcome=permission_denied. NO InvitationRevoked outbox row.
AcceptDuringSignIn (internal)(no ReBAC call — the OIDC sign-in callback is the trust boundary)resolved User principalinvitation:<uuid> (in-tx CAS UPDATE)n/a — the aggregate's terminal-state guard surfaces ErrAlready{Accepted,Expired,Revoked} to the callback.

The 403 body on this surface is a PermissionDenied schema (NOT a Problem with code: permission_denied) — established by the ReBAC platform contract and reused here verbatim. The richer body carries reason, relation_path, the missing relation, and the correlation_id.

Error taxonomy

The closed Problem.code set this surface emits, exactly as defined in internal/transport/http/v1/invitations/errors.go. The Origin column names the layer (aggregate / repo / service / handler / transport) so a future maintainer knows where to grep when the code changes.

HTTP statusProblem.codeOriginTrigger
400invalid_domain_idhandlerPath {id} was not a non-zero UUID.
400invalid_invitation_idhandlerPath {invitationId} was not a non-zero UUID.
400invalid_bodyhandler / serviceBody was not valid JSON, carried unknown fields, was missing external_subject, or the service surfaced ErrEmptyInvitationCreate / aggregate ErrInvariant (non-tuple branch).
400invalid_ttlhandlerttl_seconds outside [60, 604800].
400invalid_statushandlerList status query parameter outside {pending, accepted, revoked, expired, all}.
400invalid_limithandlerList limit query parameter outside [1, 200].
400invalid_cursorhandlerList cursor was tampered or malformed.
401unauthenticatedhandlerNo resolved principal.
403(PermissionDenied)transportReBAC denied the operation (separate schema, not Problem).
404invitation_not_foundrepo / handlerAggregate not present, OR row exists in a different Domain than the path Domain (cross-Domain match collapses into the same response so neither Get nor Revoke can be used as a cross-Domain enumeration oracle). The repo also surfaces this code on a Create against a missing parent Domain (ErrForeignKeyMissing).
409invitation_already_pendingrepo (typed *InvitationAlreadyPendingError)A pending Invitation already exists for this (domain_id, external_subject) pair. The 409 detail carries the existing row's id.
409invitation_already_acceptedservice / aggregateRevoke targeted a row already in accepted state.
409invitation_already_expiredservice / aggregateRevoke targeted a row already in expired state.
413request_body_too_largehandlerBody exceeded the 8 KiB invitation ceiling.
422too_many_initial_tupleshandler / aggregateinitial_tuples length exceeded 32.
422invitation_object_out_of_scopehandler / aggregateAn initial_tuples[].object is outside the closed prefix set {domain:<this-id>, project:<uuid>, group:<uuid>}.
422invalid_caveat_contexthandler / aggregateAn initial_tuples[].caveat_context failed JSON-encodable lossless round-trip.
500internalhandlerUnexpected error (detail is generic; the underlying error text NEVER leaks to the wire).

Every Problem detail on this surface carries the (REQ-xxx, PX-0029) trailer so reviewers can grep production logs back to the originating requirement.

Audit & outbox contract

Every successful mutation writes exactly one audit row AND exactly one outbox event in the same transaction. The internal JIT acceptance fork emits a single InvitationAccepted outbox row whose payload carries the denormalised tuple_objects array; the downstream relation-tuples projector reads that array off the event and writes one SpiceDB tuple per entry — there is no per-tuple outbox event. List denials and the per-row visibility filter share a single page-level row carrying item_count + status + authz_errors. The mutator paths (Create, Revoke) ALSO emit through the service-layer sink, so the transport-layer rows are a complementary signal that captures the request-level rejection cases the service never reaches.

OperationOutcomeAudit relationAudit outcomeOutbox event
CreateInvitationsuccessinvitation.creategranted (transport) / success (service)InvitationCreated
CreateInvitation403invitation.createpermission_denied(none)
CreateInvitation4xx invariant (invalid_body, invalid_ttl, too_many_initial_tuples, invitation_object_out_of_scope, invalid_caveat_context)invitation.create (CaveatContext.fields names the offending field)invariant_violation(none)
CreateInvitation409 already_pendinginvitation.create (CaveatContext.fields=["external_subject"])conflict(none)
CreateInvitation413 body_too_largeinvitation.create (CaveatContext.fields=["body"])invariant_violation(none)
CreateInvitation500invitation.createinternal_error(none)
ListInvitationssuccessinvitation.list (page-level row carrying item_count, status, optional authz_errors)granted (transport) / success (service)(none)
ListInvitations403invitation.listpermission_denied(none)
ListInvitations4xx invariant (invalid_status, invalid_limit, invalid_cursor)invitation.list (CaveatContext.fields names the offending field)invariant_violation(none)
GetInvitationsuccessinvitation.read (CaveatContext.invitation_id,.domain_id)granted (transport) / success (service)(none)
GetInvitation403invitation.readpermission_denied(none)
GetInvitation404 (missing or cross-Domain)invitation.read (CaveatContext.invitation_id, optional.reason=cross_domain)not_found(none)
GetInvitation500invitation.readinternal_error(none)
RevokeInvitationsuccessinvitation.revokegranted (transport) / success (service)InvitationRevoked
RevokeInvitation204 idempotent (already-revoked)invitation.revoke (CaveatContext.already_revoked=true)granted(none) — the mutator never runs on the idempotent path
RevokeInvitation403invitation.revokepermission_denied(none)
RevokeInvitation404 (missing or cross-Domain)invitation.revoke (CaveatContext.invitation_id, optional.reason=cross_domain)not_found(none)
RevokeInvitation409 already_accepted / already_expiredinvitation.revokeconflict(none)
RevokeInvitation500invitation.revokeinternal_error(none)
AcceptDuringSignIn (internal)successinvitation.accept (CaveatContext.invitation_id,.domain_id,.accepted_user_id)successInvitationAccepted (single row; payload's tuple_objects array is consumed by the relation-tuples projector to write one SpiceDB tuple per entry — no per-tuple outbox event)
AcceptDuringSignIn (internal)failure (terminal-state guard, callback rollback)invitation.acceptfailure(none — the transaction rolls back)
ExpirePending (sweeper)successinvitation.expire (single sweep-level row carrying item_count)successone InvitationExpired per transitioned row

The service-layer audit emission uses success / failure for the outcome vocabulary (mirroring the canonical audit.Sink); the transport-layer rows use granted / permission_denied / invariant_violation / conflict / not_found / internal_error so dashboards can pivot on the richer transport vocabulary while the service rows anchor the long-tail forensic trail.

Worked example — happy-path create + sign-in acceptance

The four-step trace below shows a typical end-to-end invitation lifecycle: an operator creates an Invitation, the invitee signs in via OIDC, and the JIT acceptance fork lands the User upsert + the relation-tuple writes in the same transaction as the InvitationAccepted outbox row.

  1. Operator stages the Invitation:

    bash
    curl -X POST \
         -H 'Content-Type: application/json' \
         -H 'X-Correlation-Id: 0190a8b8-a0c0-7a0a-8a0a-cccccccccccc' \
         -d '{
               "external_subject": "ada@example.com",
               "ttl_seconds": 86400,
               "initial_tuples": [
                 { "relation": "member",
                   "object": "project:0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0aa" }
               ]
             }' \
         https://api.plexsphere.example/v1/domains/0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1/invitations
  2. The Create transaction lands one outbox row alongside the new pending Invitation aggregate:

  • InvitationCreated (carrying invitation_id, domain_id, external_subject_pseudonym, expires_at, initial_tuples).
  1. The invitee signs in via the OIDC IdP. The sign-in callback resolves the IdP sub claim, runs the JIT User upsert, finds the matching pending Invitation by (domain_id, external_subject), and invokes AcceptDuringSignIn — all inside one pgx transaction.

  2. The acceptance transaction lands the following outbox row chain:

  • UserCreated (or UserSignedIn, depending on whether the User was just upserted).

  • InvitationAccepted (carrying invitation_id, domain_id, accepted_user_id, accepted_at, and the denormalised tuple_objects array — for the example above the array contains exactly one entry, {relation: "member", object: "project:0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0aa"}).

    The downstream relation-tuples projector consumes the InvitationAccepted event, reads tuple_objects off the payload, and writes one SpiceDB tuple per entry (in the example above: relation=member, object=project:0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0aa, subject=user:<accepted_user_id>). There is NO per-tuple RelationTupleEnqueued outbox row — the projector's input is the single InvitationAccepted event.

    Both outbox rows share the same transaction_id (pg_current_xact_id()) so consumers can order strictly by commit time. A failure at any step rolls the entire transaction back so the row is never left half-accepted.

Cross-references