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/{invitation_id}GetInvitationdomain:<id>#readinvitation.read(none)n/a
DELETE/v1/domains/{id}/invitations/{invitation_id}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/{invitation_id}. 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 {invitation_id} 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 is a sentence written in the transport layer. The underlying error chain never reaches the wire; it appears only in the log record for the same failure. Both the body and that record carry the same correlation_id, so an operator holding a detail from a caller can pivot to the cause by that id.

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 ​