API Reference
This is the complete API reference for the Billettsalg backend. All endpoints are served by Azure Functions under the /api path prefix, and authentication is via JWT bearer tokens unless noted otherwise.
API Conventions
These conventions apply across all endpoints. Understanding them will save you time when integrating.
Response Envelope
Every endpoint returns a consistent JSON envelope — check success first, then read data or error:
{ "success": true, "data": T }
{ "success": false, "error": "string", "message": "string" }Organization Scoping
Most authenticated endpoints require a ?organizationId={organizationId} query parameter. The middleware uses this to resolve the caller's membership and role within that specific organization. Forgetting this parameter is a common source of 400 errors.
Pagination
List endpoints that return large datasets support a limit query parameter and return a continuationToken for cursor-based pagination. Pass the token as ?continuationToken={value} to fetch the next page.
Rate Limiting
The magic-link and verify authentication endpoints enforce per-IP rate limiting to prevent abuse. Rate limiting is enabled in all environments and cannot be bypassed for security reasons.
Soft Deletes
Events, Registrations, and Users support soft-delete via a deletedAt timestamp. Soft-deleted records are excluded from normal queries but can be restored by an admin.
Authentication
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/auth/magic-link | Request login email | Public |
| POST | /api/auth/verify | Verify magic link token | Public |
| POST | /api/auth/verify-code | Verify 6-digit code from email | Public |
| GET | /api/auth/me | Get current user | Required |
Events
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/events | List all events | Required |
| GET | /api/events-active | Get active event | Required |
| GET | /api/events-overview | Get events with stats | Required |
| GET | /api/events/{id} | Get event details | Required |
| POST | /api/management/events | Create event 1 | Admin |
| PUT | /api/management/events/{id} | Update event 1 | Admin |
| DELETE | /api/management/events/{id} | Soft-delete event 1 | Admin |
| PATCH | /api/management/events/{id}/restore | Restore soft-deleted event 1 | Admin |
| PUT | /api/management/events/{id}/lock | Toggle sales lock 1 | Admin |
| PUT | /api/management/events/{eventId}/showtimes/{showtimeId}/external | Update a showtime's external sales 1 | Admin |
1 Creating an event, updating one, toggling its sales lock, soft-deleting and restoring one, or recording external sales on one of its showtimes additionally requires a full organization. An organization in sellingPartner mode is refused with 403 ORG_MODE_RESTRICTED before anything is written — see Organization Capability Mode.
enableQuotas field
The create/update event request body accepts an optional enableQuotas boolean field. When set to true, allocation limits are enforced during order creation for this event. Defaults to false.
Shared events
Shared Event handlers use the same JWT and ?organizationId={organizationId} conventions as the rest of the API. The Azure Functions trigger is configured as anonymous, but each handler performs application-level authentication and authorization. Creation and mutation also require SHARED_EVENTS_ENABLED=true; a configured organization allowlist can narrow mutation access.
The role labels below summarize the effective access checks. Named capability grants can delegate specific Shared Event tasks without granting a broader organization role.
Core and invitation endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/shared-event-capabilities/create | Check whether the current organization can create Shared Events | Organization Admin |
| POST | /api/shared-events | Create a draft Shared Event 4 | Organization Admin or Super Admin |
| GET | /api/shared-events | List Shared Events for the current organization; scope=all is Super Admin only | Participant Member or Super Admin |
| GET | /api/shared-events/{sharedEventId} | Get the caller-authorized Shared Event view | Participant Member, invited viewer, or Super Admin |
| GET | /api/shared-events/{sharedEventId}/capacity | Read authoritative Shared Event capacity | Participant Member or Super Admin |
| GET | /api/shared-event-invitations/pending | List invitations available to organizations the caller administers | Authenticated Organization Admin |
| POST | /api/shared-event-invitations/pending/accept | Accept an in-app invitation 4 | Admin of accepting organization |
| GET | /api/shared-event-invitations/preview | Preview a token invitation without exposing invalid-token details | Authenticated |
| POST | /api/shared-event-invitations/accept | Accept a token invitation 4 | Admin of accepting organization |
| POST | /api/shared-events/{sharedEventId}/invitation-links | Create an invitation link and optionally attempt email delivery | manageParticipants and Organization Admin |
| GET | /api/shared-events/{sharedEventId}/invitation-links | List invitation metadata; token hashes are never returned | manageParticipants |
| DELETE | /api/shared-events/{sharedEventId}/invitation-links/{linkId} | Revoke an invitation link | manageParticipants and Organization Admin |
| POST | /api/shared-events/{sharedEventId}/participants/{orgId}/accept | Accept a roster invitation 4 | Admin of target organization |
| POST | /api/shared-events/{sharedEventId}/participants/{orgId}/decline | Decline a roster invitation | Admin of target organization |
| POST | /api/shared-events/{sharedEventId}/participants/{orgId}/leave | Leave a Shared Event | Admin of target organization |
4 Creating a Shared Event, or joining one — accepting a roster invitation, an in-app invitation or a token invitation, and being added directly to a roster — additionally requires a full organization. An organization in sellingPartner mode is refused with 403 ORG_MODE_RESTRICTED before the Shared Event is read or written and before any Event projection is materialized into its partition — see Organization Capability Mode. Creation checks two organizations: the organizer (sharedEvents.create) and every organization listed as an initial invitee (sharedEvents.join). The organizer/acting-organization check runs before the rate-limit bucket is read or written, so a restricted owner never consumes or observes the mutation budget; the same is true at all three invitation-acceptance routes, which are keyed on the caller's own organization. The invitee checks necessarily run after the request body is parsed — the organization being added is only knowable from the body — and therefore after the organizer's rate-limit bucket has already been consumed, but still before any Shared Event, roster entry, audit entry, projection or notification is written. Leaving, declining and being removed are deliberately not restricted, and neither are the read routes: a restriction must never trap an organization in a Shared Event it cannot leave. GET /api/shared-event-capabilities/create keeps its existing 200 { canCreateSharedEvents, reason } contract and reports false with reason organization_mode_restricted for a selected sellingPartner organization; it is never converted into a 403.
Invalid, expired, used, revoked, or unavailable invitations return generic not-found responses so callers cannot probe invitation state.
Organizer and participant administration
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| PATCH | /api/shared-events/{sharedEventId} | Edit canonical details or pricing | Organization Admin with manageSharedDetails or managePricing |
| PATCH | /api/shared-events/{sharedEventId}/capacity | Edit pool capacity | Super Admin |
| DELETE | /api/shared-events/{sharedEventId} | Retire a draft Shared Event safely | Super Admin |
| POST | /api/shared-events/{sharedEventId}/participants | Add an organization directly to the roster 4 | Super Admin |
| DELETE | /api/shared-events/{sharedEventId}/participants/{orgId} | Remove a participant | Organization Admin with manageParticipants |
| PATCH | /api/shared-events/{sharedEventId}/lifecycle | Move the event between allowed lifecycle states | Organization Admin with manageSharedDetails |
| PATCH | /api/shared-events/{sharedEventId}/participants/{orgId}/quota | Update one participant quota | manageCapacity |
| PATCH | /api/shared-events/{sharedEventId}/quotas | Update participant quotas in bulk | manageCapacity |
| POST | /api/shared-events/{sharedEventId}/capacity-policy | Change the capacity policy through the guarded transition workflow | manageCapacity |
| GET | /api/shared-events/{sharedEventId}/participants/{orgId}/users | List eligible users for owner or grant administration | Organization Admin with manageOwners, or Super Admin |
| PUT | /api/shared-events/{sharedEventId}/grants/{granteeUserId} | Issue or update a named-user capability grant | Organization Admin with manageOwners |
| DELETE | /api/shared-events/{sharedEventId}/grants/{granteeUserId} | Revoke a named-user grant | Organization Admin with manageOwners |
| GET | /api/shared-events/{sharedEventId}/audit | Read the paginated Shared Event audit feed | Authorized participant or Super Admin |
Reporting, channels, and operational endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/shared-events/{sharedEventId}/reports/combined | Get the combined event report | Owner, viewCombinedReports, or Super Admin |
| GET | /api/shared-events/{sharedEventId}/reports/participant | Get a participant report; defaults to the caller's organization | Participant for own organization; owner or Super Admin for an allowed target |
| GET | /api/shared-events/{sharedEventId}/external-sales-channels | List configured external sales channels with protected references redacted | Participant Member, invited organization member, or Super Admin |
| GET | /api/shared-events/{sharedEventId}/external-sales-channels/{source} | Get one external sales channel with protected references redacted | Participant Member, invited organization member, or Super Admin |
| PUT | /api/shared-events/{sharedEventId}/external-sales-channels/{source} | Assign or update the responsible participant organization | manageExternalSales and Organization Admin |
| DELETE | /api/shared-events/{sharedEventId}/external-sales-channels/{source} | Disable a channel without deleting historical attribution | manageExternalSales and Organization Admin |
| GET | /api/shared-event-organizations/search | Search active organizations for direct roster administration 5 | Super Admin |
| POST | /api/shared-events/{sharedEventId}/operation-status | Refresh recovery status and advance queued projection work | Authorized participant or Super Admin |
| POST | /api/shared-events/{sharedEventId}/sync-projections | Request repair or synchronization of organization projections | Organization Admin with manageSharedDetails |
5 Organizations in sellingPartner mode are excluded from these results, so a Super Admin cannot select one as a Shared Event participant — see Organization Capability Mode. This endpoint is also the picker behind delegated selling-partner discovery, where a sellingPartner organization is the intended target, so the exclusion is selected per call:
| Query parameter | Values | Default | Effect |
|---|---|---|---|
purpose | sharedEvent, delegatedSales | sharedEvent | sharedEvent excludes sellingPartner organizations; delegatedSales returns them, for delegated selling-partner discovery. An unrecognized value is a 400; the default is never widened by a typo. |
The mode is read as part of the same organization query, so the filter adds no per-result lookup, and it is never returned to the client: results stay { id, name } and the mode of any organization is not disclosed. A failed query surfaces as the route's existing error rather than an unfiltered page.
The organization search, grant, operation-status, and projection-sync routes are administrative or operational endpoints used by management interfaces. They are not public discovery APIs or substitutes for the organizer and participant workflows.
Channel reads also give authenticated members of an invited organization a bounded, read-only view before the invitation is accepted. They receive channel configuration only; protected connectionRef values are removed. Active participants have the same redaction unless they are viewing a channel assigned to their own organization. Owners and Super Admins can receive the protected references. Callers outside the participant or invited organization scope are denied.
Delegated sales (selling partners)
Delegated sales let an event owner grant another organization a scoped, self-service ability to sell tickets on its behalf for a normal (non-Shared) event, with no cancel-after-approval/refund/transfer/reconcile/invoice/archive/configuration authority over the event. This is unrelated to Shared events co-ownership; see Data Model → Delegated sales for the full field-level schema.
As of v1.2.0, the primary buyer-facing flow is member self-service: an approved member of the partner organization orders the delegated event through the ordinary member order-creation flow, the order is created pending, and the partner organization's own Ticket Manager+ approves and delivers it through an approval queue. Catalog visibility and order creation are each gated by their own rollout switch — see Environment Variables → Delegated member ordering configuration. A staff-created (on-behalf) order route is retained as a secondary, compatibility-only path for cases self-service can't cover.
Owner-side management endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/organizations/{organizationId}/events/{eventId}/sales-delegations | Invite a partner organization to sell tickets for this event 3 | Owner Organization Admin+ |
| GET | /api/organizations/{organizationId}/events/{eventId}/sales-delegations | List delegations for this event 3 | Owner Organization Admin+ |
| POST | /api/organizations/{organizationId}/events/{eventId}/sales-delegations/{delegationId}/revoke | Revoke an invited or active delegation 3 | Owner Organization Admin+ |
3 Inviting, listing or revoking a sales delegation additionally requires the owning organization to be full. An organization in sellingPartner mode is refused with 403 ORG_MODE_RESTRICTED before the rate-limit bucket, the event read, the request body and any delegation or reverse-index write — see Organization Capability Mode. The restriction is keyed on the owning organization only; the invited partner's own mode is irrelevant, and the partner-side endpoints below are not restricted.
POST .../sales-delegations — discovery input is role-gated and mutually exclusive. The request body accepts exactly one of two fields; the target organization is always ultimately resolved server-side, never taken as free-form client input:
| Field | Type | Who may send it | Description |
|---|---|---|---|
partnerAdminEmail | string (email) | Any caller | Email address of an administrator in the target organization. The server resolves it to exactly one eligible organization via resolvePartnerOrganizationByAdminEmail (api/src/shared/delegation-partner-resolution.ts), reusing the Shared Event admin reverse-lookup. |
partnerOrganizationId | string | Super Admin only | A raw organization ID, intended only for the super-admin-only organization-search flow (GET /shared-event-organizations/search?purpose=delegatedSales, which unlike the Shared Event default still returns sellingPartner organizations — the intended partners here) — never a value a non-super-admin client should construct or send. |
{ "partnerAdminEmail": "admin@partner-choir.example" }{ "partnerOrganizationId": "org-id-from-search-result" }Supplying both fields, or neither, fails schema validation and returns HTTP 400 (Provide exactly one of partnerOrganizationId or partnerAdminEmail) — there is no precedence rule between the two.
A successful invite (either path) returns HTTP 201:
{
"success": true,
"data": {
"delegation": { "id": "...", "status": "invited", "partnerOrganizationId": "org-id", "...": "..." },
"invitationLink": "https://app.example/sales-delegations/accept?token=...",
"reverseIndexSync": "synced"
}
}invitationLinkembeds a one-time token; only its SHA-256 hash is persisted (invitationTokenHash), so the raw link is never retrievable again after this response — copy it immediately.- Inviting a partner organization that already has an
invitedoractivedelegation for this event is idempotent: the existing delegation and (if still available) its link are returned rather than creating a duplicate. - Eligibility for either path: the resolved target organization must be active (not soft-deleted) and must not be the caller's own organization. There is no separate partnership-approval step beyond that.
Error conditions specific to discovery:
- 400 — Both
partnerAdminEmailandpartnerOrganizationIdpresent, or neither present (schema validation failure). - 400 —
partnerOrganizationIdresolves to the caller's own organization (Cannot delegate sales to your own organization), or to a Shared Event projection. - 403 —
partnerOrganizationIdsent by a caller who is not a Super Admin. This is a hard role gate, independent of whether the ID would otherwise be valid — an ordinary owner admin can never use this field, by design, to probe organization IDs. - 404 —
partnerAdminEmailcould not be resolved to exactly one eligible partner organization. This single generic outcome (GENERIC_PARTNER_RESOLUTION_ERROR: "No partner organization could be invited with that email address", with added response-timing jitter) is returned identically whether the address is unknown, belongs to a non-admin, belongs only to an ineligible/soft-deleted organization, belongs only to the caller's own organization, or is an administrator of more than one eligible organization (ambiguous target) — the anti-enumeration contract deliberately makes these indistinguishable to the caller. The frontend never elaborates on or re-interprets this message.
POST .../sales-delegations/{delegationId}/revoke accepts { "revokedReason": "..." } — revokedReason is required and must be non-empty. Revoking an already-revoked delegation is idempotent (returns success without changing anything). Revocation takes effect immediately against the owner's authoritative Event.salesDelegations[], independent of how quickly the partner's own reverse-index projection catches up — the partner immediately loses order-creation, approval/delivery, and reporting rights even before its own list or catalog projection finishes reflecting the change.
GET .../sales-delegations opportunistically self-heals pending or failed reverse-index sync entries as a side effect of listing.
Partner-side acceptance endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/sales-delegations/accept | Accept an invitation using the one-time invitation link's token | Partner Organization Admin+ |
| POST | /api/organizations/{partnerOrganizationId}/sales-delegations/{delegationId}/decline | Decline a pending invitation | Partner Organization Admin+ |
POST /api/sales-delegations/accept accepts a composite token of the form {ownerOrganizationId}.{eventId}.{delegationId}.{rawToken} (the value embedded in invitationLink) and transitions the delegation from invited to active.
POST .../sales-delegations/{delegationId}/decline requires no token — it is an in-app action, valid only from invited status, and is idempotent if the delegation is already declined.
The partner-side endpoints in this section, and every delegated operational endpoint below, are deliberately not restricted by Organization Capability Mode: accepting or declining an inbound invitation, the delegated catalog, member self-ordering, approval/delivery, sale/return reporting, order history and partner-scoped sales reporting are precisely what a sellingPartner organization exists to do.
Member self-service and partner reporting endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/organizations/{partnerOrganizationId}/delegated-events | List events currently delegated to this organization | Any approved member of the partner organization |
| GET | /api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/sales-context | Get showtimes, ticket types, and a capacity hint for selling | Ticket Manager+ |
| POST | /api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/self | Member self-service: create a pending delegated order for the authenticated caller | Any approved member of the partner organization |
| POST | /api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/{orderId}/cancel | Cancel a pending delegated order — by the buyer themselves, or by the partner's own Ticket Manager+ on the buyer's behalf | Buyer, or Ticket Manager+ |
| GET | /api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/queue | Partner's approval queue for its own members' self-service orders, filterable by pending or approved status | Ticket Manager+ |
| POST | /api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/{orderId}/approve | Approve a pending self-service order | Ticket Manager+ |
| POST | /api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/{orderId}/deliver | Mark an approved order delivered | Ticket Manager+ |
| POST | /api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/{orderId}/approve-and-deliver | Approve and deliver a pending order in one step | Ticket Manager+ |
| GET | /api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders | List this partner organization's own orders for the event | Ticket Manager+ |
| POST | /api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/{orderId}/report-sold | Report sold quantity on a delegated order | Ticket Manager+, or the buyer for their own order |
| POST | /api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/{orderId}/report-returned | Report returned quantity on a delegated order | Ticket Manager+, or the buyer for their own order |
| POST | /api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders | Deprecated, retained as a secondary path. Staff-recorded (on-behalf) order for an approved member of the partner's own organization, created already delivered with no approval step | Ticket Manager+ |
GET .../delegated-eventsreturns only refs whose denormalizedstatusis"active"— invited, declined, and revoked delegations never appear in this list.GET .../sales-contextreturns per-showtime{ venueCapacity, remaining, showtimeId }figures. This is an advisory hint, not a reservation — final capacity is enforced atomically at order creation/report-sold time, not here.POST .../orders/selfrequires anactivedelegation and the same live checks as any other delegated write. It is unconditionally closed off the ordinaryPOST /api/ordersroute — an event carrying a delegated projection is rejected there with a wrong-endpoint error, independent of role or projection state, so a delegated order can never be built against the wrong organization's capacity ledger. WhenDELEGATED_MEMBER_ORDERING_ENABLEDis off, this endpoint returns HTTP 503 with error codeDELEGATED_MEMBER_ORDERING_UNAVAILABLEand creates nothing.POST .../orders/{orderId}/cancelonly transitions apendingorder to cancelled — the same rule as an ordinary member order. A partner Ticket Manager+ does not gain the owner-admin ability to cancel an order that is already approved or delivered.GET .../orders/queueand theapprove/deliver/approve-and-deliveractions are scoped to orders created under the partner's own currently-active delegation for this event; they never return or act on the owner's own orders or another partner's orders. All three re-verify the live delegation on every call, so a delegation revoked mid-session immediately blocks further action, even on orders already in the queue.- The staff on-behalf
POST .../ordersendpoint requires the buyer to be an approved member of the partner's own organization (never the owner's members or another partner's members), and creates the order alreadystatus: "delivered"withsoldQuantity: 0andreturnedQuantity: 0. A capacity conflict at submission time is rejected with HTTP 409CAPACITY_EXCEEDEDand creates nothing. GET .../ordersis scoped server-side tosoldByOrganizationId === partnerOrganizationId; it never returns the owner's own orders or another partner's orders, and it excludes any of this partner's orders that have since been transferred away from it.POST .../report-soldandPOST .../report-returnedboth re-verify, on every call, that the caller's organization still holds an active delegation for this event and still holds selling authority over the specific order (assertDelegatedOrderAuthority) — a delegation revoked mid-session immediately blocks further reporting, even on orders already created. Report-sold enforces the same atomic capacity gate as owner-side reporting. Report-returned mirrors the owner-side return policy exactly: the event-date eligibility check is bypassed only for a partner Ticket Manager+ acting on a member's behalf, the same as an owner-side manager acting on behalf. A member reporting a return on their own delegated order remains subject to the same rule as an ordinary own-organization return and must wait for the relevant showtime or event to pass.
What delegated sales never expose
No delegated-sales endpoint supports refunding, transferring, reconciling, settling, invoicing, or archiving an order, nor changing any event, pricing, ticket-type, or capacity configuration, nor cancelling an order once it is approved or delivered. Those remain exclusively on the owner-side endpoints documented elsewhere in this reference, gated to the owner organization's own roles. The delegated projection that makes a concert visible in the partner's own catalog is a display index only — it is never consulted for an authorization decision; every write re-derives authority from the owner's live Event.salesDelegations[].
Orders
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/orders | List orders (with filters) | Ticket Manager+ |
| POST | /api/orders | Create order request | Member+ |
| GET | /api/orders/{id} | Get order details | Ticket Manager+ |
| POST | /api/orders/{id}/approve | Approve order | Ticket Manager+ |
| POST | /api/orders/{id}/deliver | Mark order delivered | Ticket Manager+ |
| POST | /api/orders/{id}/cancel | Cancel order | Ticket Manager+ |
| DELETE | /api/orders/{id} | Delete order | Ticket Manager+ |
| PATCH | /api/orders/{id}/archive | Archive or unarchive order | Ticket Manager+ |
| POST | /api/orders/archive/preview | Preview eligible completed orders for one event | Ticket Manager+ |
| POST | /api/orders/archive | Archive one batch of eligible completed orders for one event | Ticket Manager+ |
| PATCH | /api/orders/{id}/admin-note | Update admin note on order | Ticket Manager+ |
| POST | /api/orders/{id}/transfer | Transfer all or part of an order to another member | Ticket Manager+ |
| GET | /api/me/orders | Get current user's orders | Member+ |
Both event-scoped archive endpoints accept { "eventId": "..." }. A cancelled order is eligible. A settled order is eligible only when it has a valid frozen invoiceBasis and invoicedAt. Deleted and already archived orders are excluded. The preview returns eligibleCount, statusBreakdown, blockedSettledUninvoiced, and blockedInvalidInvoiceBasis. The archive endpoint uses the same candidate predicate, never overrides blockers, processes at most 100 orders per call, and returns the two blocked counts alongside processed, conflicts, skipped, remaining, hasMore, and completed.
If an unexpected failure occurs after at least one order was durably archived, the endpoint returns HTTP 207 with success: true and completed: false in data instead of discarding progress. The result includes the durable counters plus bounded failureCode and failureReason values. remaining is numeric when the follow-up count succeeds, or null when it cannot be determined; hasMore remains true in the unknown case. Clients must not automatically retry this mutation because each successful call changes the next candidate set.
Archiving retains lifecycle, sales, returns, reconciliation, invoice, and financial records. The individual PATCH endpoint accepts { "archived": true | false }. Organization admins can explicitly override a blocked settled order with { "archived": true, "override": { "confirmed": true } }. Ticket Managers and Treasurers receive 403 ARCHIVE_OVERRIDE_FORBIDDEN; normal blocked requests receive 409 INVOICE_REQUIRED or INVALID_INVOICE_BASIS. Override evidence records the actor, display name, timestamp, archive timestamp, and exact blockers. It remains after unarchive. Existing archived legacy orders remain archived, but new gating applies after they are unarchived.
Printed-ticket lifecycle
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/orders/{id}/report-sold | Record the new cumulative number sold from a delivered order | Order owner or Admin |
| POST | /api/orders/{id}/return | Record an incremental return of unsold printed tickets | Order owner or Ticket Manager+ |
| POST | /api/orders/{id}/force-record-sold | Force-record a sale that exceeds the remaining venue capacity 1 | Admin |
| POST | /api/orders/{id}/approve-reconciliation | Approve a fully accounted order and freeze its invoice basis | Ticket Manager+ |
1 Force-recording a sale additionally requires a full organization. An organization in sellingPartner mode is refused with 403 ORG_MODE_RESTRICTED before the request body is read and before anything is read or written — see Organization Capability Mode. Every other endpoint in this section, and every delegated partner reporting endpoint, is not restricted.
POST /api/orders/{id}/report-sold accepts:
{
"soldQuantity": 4,
"soldTypeQuantities": [
{ "ticketTypeId": "adult", "soldQuantity": 2 }
]
}soldQuantity is the new absolute total sold for the order. Each optional per-type quantity is the additional quantity sold in this request, and the per-type sum must equal the increase from the previous total. The order must be delivered, the caller must own it or be an Admin, and the new total cannot exceed the ordered quantity minus returns. The local venue-capacity conflict path returns HTTP 409 with errorCode: "CAPACITY_EXCEEDED" and data.availableCapacity plus data.requestedDelta. A Shared Event sold-out or capacity-freeze conflict can return the same HTTP status and error code without capacity detail fields. A successful response returns the updated order in data.
POST /api/orders/{id}/return accepts:
{
"returnQuantity": 2,
"returnTypeQuantities": [
{ "ticketTypeId": "adult", "returnQuantity": 2 }
]
}The return quantity and optional per-type values are incremental. They cannot exceed the outstanding balance. Members can return only their own tickets and only after the relevant showtime or event has passed. Ticket Managers, Treasurers, Admins, and Super Admins can record a return on behalf of a member without the date restriction. The order remains delivered while tickets are outstanding and moves to awaitingReconciliation when sold plus returned equals the ordered quantity.
POST /api/orders/{id}/force-record-sold accepts the same soldQuantity and optional soldTypeQuantities as report-sold, plus a mandatory reason. It is the owner's deliberate override of its own venue capacity: it is Admin/Super-Admin only, it bypasses the capacity ceiling, it still moves the capacity ledger, and it appends an immutable oversale_recorded audit entry with server-derived actor, timestamp, and quantities. The order must be delivered, and the new total still cannot exceed the ordered quantity minus returns. A request that does not increase the sold total is idempotent and writes nothing. Because the override is an owner-side capability, it requires a full organization.
POST /api/orders/{id}/approve-reconciliation requires no request body. It accepts only an awaitingReconciliation order where sold plus returned equals the issued quantity. Approval changes the status to settled, records the approver, and stores an immutable invoice-basis snapshot.
The snapshot contains only sold quantities multiplied by the unit prices saved with the order. If every ticket was sold, older orders can derive the per-type quantities from the ordered quantities. If no tickets were sold, the snapshot is an empty zero-value basis. A partial sale requires complete and consistent soldTypeQuantities, plus consistent return-type detail when present. Incomplete or contradictory partial history returns 400 and blocks settlement. totalAmount is never an invoice fallback.
Approval does not set invoicedAt, create an invoice, or send an invoice. A successful response returns the settled order in data.
Lifecycle failures use the standard error envelope. Expect 400 for invalid quantities or states, 403 for ownership or role failures, 404 for an unknown order, 409 for capacity conflicts, and 503 when Shared Event capacity is temporarily unavailable.
Order Transfers
The transfer endpoint allows Ticket Managers to move tickets between members — either fully reassigning an order or splitting it into source and destination orders. A partial transfer commits as a single same-organization-partition atomic Cosmos transactional batch (replace source, create destination), and every request is idempotent per client-supplied transferRequestId, so a retried or replayed request can never duplicate a transfer or leave one side updated without the other.
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/orders/{id}/transfer | Transfer all or part of an order to another member | Ticket Manager+ |
POST /api/orders/{id}/transfer accepts:
{
"toUserId": "user-id-of-destination",
"transferRequestId": "550e8400-e29b-41d4-a716-446655440000",
"tickets": [
{ "ticketTypeId": "adult", "quantity": 2 },
{ "ticketTypeId": "child", "quantity": 1 }
],
"reason": "Member requested swap"
}Field requirements:
toUserId(required, string) — ID of the member receiving the tickets. Must be an approved member of the organization and different from the current owner.transferRequestId(required, UUID string) — Client-generated idempotency key for this transfer intent (e.g.crypto.randomUUID()). Resend the SAME value for retries of the same logical transfer; see Idempotency below.tickets(array, at least one entry, each{ ticketTypeId, quantity }withquantity >= 1) — Exact per-ticket-type quantities to transfer. Only list the ticket types actually moving.quantity(integer >= 1) — Alternative totickets: a bare aggregate quantity, proportionally split across the order's ticket-type lines. At least one ofquantityorticketsis required; use one form per request. If both are present,ticketsis authoritative andquantityis ignored — the handler does not reject the combination.reason(optional, string) — Free-form reason for the transfer, stored on the transfer history entry.
Response format:
A successful transfer returns HTTP 200 with a flat TransferResult in data (no nested wrapper):
{
"success": true,
"data": {
"transferType": "partial",
"quantity": 2,
"recipientUserId": "user-id-of-destination",
"recipientName": "Recipient Name",
"sourceOrderId": "order-id-of-remaining-source",
"sourceOrderStatus": "approved",
"sourceRemainingQuantity": 1,
"sourceHistoryTab": "active",
"sourceActionUrl": "/history?tab=active&orderId=order-id-of-remaining-source",
"resultOrderId": "order-id-of-new-destination",
"resultOrderStatus": "approved",
"resultQuantity": 2,
"resultHistoryTab": "active",
"resultActionUrl": "/history?tab=active&orderId=order-id-of-new-destination"
}
}TransferResult fields (api/src/shared/types.ts):
transferType—"full"(entire order reassigned in place) or"partial"(order split into a reduced source order and a new destination order).quantity— total number of tickets moved.recipientUserId,recipientName— recipient identity for display only; no email or other PII beyond the name already shown in the transfer picker.sourceOrderId,sourceOrderStatus,sourceRemainingQuantity— the source order as it stands immediately after the transfer. For a full transfer,sourceOrderId === resultOrderIdandsourceRemainingQuantityis always0.sourceHistoryTab,sourceActionUrl— the sender's History tab (active|delivered|settled|cancelled) and deep link.sourceActionUrlis the bare/history(no order pre-selected) when the source order was fully reassigned away; otherwise/history?tab={tab}&orderId={id}with both values URL-encoded.resultOrderId,resultOrderStatus,resultQuantity— the destination (recipient) order as it stands immediately after the transfer.resultQuantityalways equalsquantity.resultHistoryTab,resultActionUrl— the recipient's History tab and deep link, same/history?tab={tab}&orderId={id}shape.
Idempotency:
The transferRequestId is stored durably on the source order's transferHistory entry for that transfer — there is no separate cache and no expiry (no TTL, no time-boxed window). A repeat request with the SAME transferRequestId and the SAME recipient/mode/quantities as a prior transfer is recognized from the point read of the source order and returns the ORIGINAL TransferResult with HTTP 200, without re-executing or duplicating anything — even if the order's status or reporting progress has since changed. A repeat request with the SAME transferRequestId but a DIFFERENT recipient, mode, or quantities is rejected with HTTP 409 TRANSFER_REQUEST_ID_REUSED and does not mutate the order. For a partial transfer, the destination order's ID is deterministically derived from (organizationId, transferRequestId), so a concurrent duplicate attempt for the identical request cannot silently create a second destination order — it collides at the Cosmos level and is resolved back to the original replay result.
Error conditions:
- 400 — Invalid request body (missing
toUserId/transferRequestId, non-UUID request ID, neitherquantitynorticketsprovided, a per-type quantity below 1), recipient not found in the organization, order status other thanapproved/delivered, or recipient same as the current owner. - 400
TRANSFER_EXCEEDS_OUTSTANDING— The requested aggregate quantity, or a specific ticket type's requested quantity, exceeds what remains outstanding (unreported) on the source order. ResponsedataincludesrequestedQuantityandavailableQuantity(andticketTypeIdfor a per-type violation) — never sold/returned totals or descriptive text. - 401 — Missing or invalid authentication.
- 403 — Caller is not a Ticket Manager or higher role.
- 404 — Order not found.
- 409
TRANSFER_BLOCKED_NO_OUTSTANDING— The source order has zero outstanding (unreported) quantity left — every unit has already been sold or returned. Nothing remains transferable, full or partial. - 409
TRANSFER_REQUEST_ID_REUSED— The sametransferRequestIdwas already used for a transfer with a different recipient, mode, or quantities.
What is transferable (outstanding-only):
A transfer can only move outstanding (unreported) ticket quantity — units that have not yet been sold or returned are the only ones eligible. Sold and returned units, and their supporting per-ticket-type evidence, are never moved or mutated by this endpoint. Outstanding is computed as max(0, totalQuantity - effectiveSoldQuantity - returnedQuantity), reusing the same effective-sold semantics as capacity/reporting elsewhere (an order that reached delivered/awaitingReconciliation/settled with no recorded soldQuantity is treated as fully sold, i.e. zero outstanding). Per-ticket-type outstanding is capped so the per-type figures never sum to more than this aggregate. Requesting more than what is outstanding — in aggregate or for any single ticket type — is rejected with TRANSFER_EXCEEDS_OUTSTANDING; if nothing is outstanding at all, the request is rejected with TRANSFER_BLOCKED_NO_OUTSTANDING. This means a full-order transfer request naturally fails once any reporting has occurred (outstanding is then less than totalQuantity), while a partial transfer for the remaining outstanding balance continues to succeed.
Reconciliation on the final outstanding transfer:
If a partial transfer moves a delivered source order's entire remaining outstanding balance (i.e., sourceRemainingQuantity would reach 0), the source order's status flips from delivered to awaitingReconciliation as part of the same atomic batch — exactly as it would if those last tickets had been reported sold or returned instead of transferred. sourceOrderStatus in the response reflects this immediately. A full transfer never triggers this, because it reassigns the whole order (there is no reduced order left behind to reconcile).
Deep linking:
sourceActionUrl and resultActionUrl are ready-to-use, URL-encoded deep links (/history?tab={tab}&orderId={id}) into the web History view, pre-selecting the correct tab and order for the sender and recipient respectively. The backend reuses these same URLs as the actionUrl on the two notifications it creates for the transfer.
Allocations (Sales Quotas)
Allocation endpoints manage per-member ticket quotas. These are accessed via the event management dashboard (not as a standalone section). Allocation limits are only enforced when enableQuotas is true on the parent event.
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/allocations | List allocations | Ticket Manager+ |
| POST | /api/allocations | Create/update allocation | Ticket Manager+ |
| DELETE | /api/allocations/{id} | Delete allocation | Ticket Manager+ |
| GET | /api/allocations/members | List members with allocation summary | Ticket Manager+ |
| GET | /api/my-allocations | Get member's own allocations | Member+ |
Commitments / Pledges
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/events/{eventId}/my-pledge | Get member's own pledge for event | Member+ |
| POST | /api/events/{eventId}/my-pledge | Submit or update commitment pledge | Member+ |
| GET | /api/events/{eventId}/pledge-summary | Get aggregate pledge summary | Member+ |
| GET | /api/management/events/{eventId}/pledges | List all pledges for event | Ticket Manager+ |
| PUT | /api/management/events/{eventId}/commitment | Open/close/convert commitment round | Admin |
External Sales
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/management/events/{eventId}/external-sales | List external sales for event | Ticket Manager+ |
| GET | /api/management/events/{eventId}/showtimes/{showtimeId}/external-sales | List external sales by showtime | Ticket Manager+ |
| POST | /api/management/events/{eventId}/showtimes/{showtimeId}/external-sales | Create external sale entry 2 | Ticket Manager+ |
| PUT | /api/management/external-sales/{id} | Update external sale 2 | Ticket Manager+ |
| DELETE | /api/management/external-sales/{id} | Delete external sale 2 | Ticket Manager+ |
| POST | /api/management/external-sales/{id}/correction | Apply an administrative correction 2 | Admin |
2 Creating, updating, deleting or correcting an external-sales ledger entry additionally requires a full organization. An organization in sellingPartner mode is refused with 403 ORG_MODE_RESTRICTED before the ledger is read or written — see Organization Capability Mode. The two GET listing routes are not restricted.
Free Tickets
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/events/{eventId}/free-tickets | List free tickets | Ticket Manager+ |
| POST | /api/events/{eventId}/free-tickets | Create free ticket entry | Ticket Manager+ |
| PUT | /api/events/{eventId}/free-tickets/{id} | Update a free ticket entry | Ticket Manager+ |
| DELETE | /api/events/{eventId}/free-tickets/{id} | Delete free ticket | Ticket Manager+ |
| GET | /api/events/{eventId}/capacity-summary | Get capacity summary for event | Required |
The update endpoint requires ?organizationId={organizationId} and a non-empty JSON object containing any of showtimeId, ticketTypeId, quantity, recipient, or reason. Quantity must be a positive integer. If the ticket type or showtime changes, it must belong to the event in the path. The response returns the updated free-ticket object in data.
For Shared Events, a quantity increase reserves and commits only the additional capacity. A decrease is treated as an administrative correction and does not release previously committed shared capacity. Safe failures include invalid input or unavailable capacity, a missing ticket or event relationship, unavailable Shared Event sales, and temporary capacity setup or synchronization.
Registrations (Direct Sales)
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/registrations | Create registration (1-hour grace period for edits) | Member+ |
| PUT | /api/registrations/{id} | Update registration (within 1 hour of creation) | Ticket Manager+ |
| DELETE | /api/registrations/{id} | Delete registration (within 1 hour of creation) | Ticket Manager+ |
| GET | /api/me/history | Get user's sales history | Member+ |
| GET | /api/me/stats | Get user's stats | Member+ |
Admin / Dashboard
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/management/dashboard/{eventId} | Get dashboard stats | Ticket Manager+ |
| GET | /api/management/dashboard/{eventId}/export | Export CSV | Ticket Manager+ |
| GET | /api/management/dashboard/{eventId}/export-excel | Export Excel | Ticket Manager+ |
| PUT | /api/management/members/{userId}/invoice/{eventId} | Issue or clear invoice state for a member and event | Ticket Manager+ |
| PUT | /api/management/registrations/{id}/invoice | Mark one registration as invoiced | Ticket Manager+ |
The member-and-event endpoint accepts { "invoiced": true } to record that an invoice was issued or sent, or { "invoiced": false } to clear that state. An empty object remains temporarily supported as a legacy toggle, but first-party clients send the explicit state. The legacy direction is based only on eligible records: when every eligible record is already invoiced, it clears them even if unresolved or invalid printed orders still exist.
Eligible records are active direct registrations and active settled printed orders with a valid frozen invoice basis. Archived settled orders remain eligible when issuing an invoice, but you must unarchive them before clearing invoice state. Unresolved and invalid-basis printed orders are not mutated. Invoice amount equals registration totalAmount plus printed-order invoiceBasis.grandTotal; it never uses TicketOrder.totalAmount.
The operation applies ETag conditions to each record. Its result reports aggregate and per-record-kind updated, unchanged, conflict, unresolved, invalid, and failed counts. The completed marker is true only when no conflict or write failure occurred. A request with conflicts or write failures returns HTTP 207 with completed: false while preserving successful updates. Repeating the same explicit request converges without toggling already-correct records.
Invitations
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/organizations/{organizationId}/invitations | Create invitation code | Admin |
| GET | /api/organizations/{organizationId}/invitations | List invitations | Admin |
| DELETE | /api/organizations/{organizationId}/invitations/{code} | Delete/revoke invitation | Admin |
| GET | /api/join/{code} | Validate invitation code | Public |
| POST | /api/join/{code} | Accept invitation and join organization | Public |
Super Admin
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/organizations | List all organizations | Super Admin |
| POST | /api/organizations | Create organization | Super Admin |
| GET | /api/organizations/{id} | Get organization details | Super Admin |
| PUT | /api/organizations/{id} | Update organization (see Organization Updates) | Org Admin or Super Admin |
| PUT | /api/organizations/{id}/mode | Change organization capability mode (see Organization Capability Mode) | Super Admin |
| DELETE | /api/organizations/{id} | Delete organization | Super Admin |
| POST | /api/organizations/{id}/members/import | Import members via CSV | Super Admin |
| GET | /api/organizations/{id}/members | List organization members | Super Admin |
| POST | /api/organizations/{id}/members | Add member to organization | Super Admin |
| PUT | /api/organizations/{id}/members/{memberId} | Update member role | Super Admin |
| DELETE | /api/organizations/{id}/members/{memberId} | Remove member from organization | Super Admin |
| GET | /api/users | List all users | Super Admin |
| GET | /api/users/{id} | Get user details | Super Admin |
| PUT | /api/users/{id} | Update user | Super Admin |
| PUT | /api/users/{id}/super-admin | Toggle super admin status | Super Admin |
| POST | /api/users/import | Import users via CSV | Super Admin |
| DELETE | /api/users/bulk-delete | Bulk delete users | Super Admin |
| POST | /api/users/bulk-assign-organization | Bulk assign users to organization | Super Admin |
| POST | /api/impersonation/audit | Log impersonation event | Super Admin |
| GET | /api/impersonation/audit | Get impersonation logs | Super Admin |
Organization Updates
The PUT /api/organizations/{id} endpoint lets that organization's Admins, and Super Admins, update organization properties.
PUT /api/organizations/{id} accepts:
{
"name": "Updated Organization Name",
"description": "A short description of the organization"
}Field requirements:
name(optional, string) — The new display name for the organization. If provided, it is trimmed and must be 1–255 characters after trimming; a blank or whitespace-only value is rejected. Omittingnameleaves the current name unchanged.description(optional, string, max 2,000 characters after trimming) — The organization's description. Omittingdescriptionleaves the current value unchanged. If provided, it is trimmed before the length check; a blank or whitespace-only value clears the description rather than being rejected — the cleared value is represented as an absent field (undefined), nevernullor a stored empty string.
Response:
A successful update returns HTTP 200 with the full updated organization object in data (all fields the organization document carries, including updatedBy):
{
"success": true,
"data": {
"id": "org-123-guid-immutable",
"name": "Updated Organization Name",
"description": "A short description of the organization",
"createdBy": "user-abc",
"createdAt": "2026-01-15T10:00:00.000Z",
"updatedAt": "2026-09-01T14:30:00.000Z",
"updatedBy": "user-xyz"
}
}Update semantics:
- Display name and description — This endpoint edits the organization's display
nameanddescription(plus a few other descriptive fields such astypeandlogo). The organizationid(also its partition key) and its identity are immutable and are never sourced from the request body. - No-op on unchanged value — If the submitted name (after trimming) matches the existing name, or
nameis omitted, the request still returns HTTP 200 with the current organization,updatedAt/updatedByare still refreshed, but no rename is recorded — see "What does not change" below. The same applies independently todescription: submitting the same normalized description (or omitting it, or clearing an already-absent description) returns HTTP 200 without emitting a description-change audit event. - Point-in-time invitation snapshots —
Invitation.organizationNameis captured once, when the invitation is created. Renaming the organization does not rewrite existing invitations: an invitation created before the rename keeps showing the old name (including at acceptance time), while invitations created after the rename capture the new name. There is no background job or read-time lookup that refreshes past invitations. - What does not change — A rename does not touch invitation snapshots, historical feedback/notification records, or any other stored copy of the old name; those keep whatever name was current when they were written.
Authorization:
The caller must be an Admin of the target organization, or a Super Admin. Members, Ticket Managers, and Treasurers of the organization cannot update organization properties via this endpoint.
Error conditions:
- 400 — Invalid request body (
namepresent but empty/whitespace-only after trimming, or exceeds 255 characters; ordescriptionexceeds 2,000 characters after trimming; or the body carriesorganizationMode, which is not writable through this endpoint — see Organization Capability Mode) - 403 — Caller is authenticated but is neither an Admin of this organization nor a Super Admin
- 404 — Organization not found
Audit: a rename (only when the normalized name actually changed) emits a structured organization.renamed telemetry event, and a description change (only when the normalized description actually changed) emits a structured organization.description_changed telemetry event — each carrying the organization ID, the acting user ID, and whether the actor was a Super Admin. Neither event records the old or new text, and a description-only update never emits the rename event (and vice versa).
Organization Capability Mode
An organization runs in one of two capability modes. This is a mode on the existing organization, not a separate kind of account:
full— the complete product. This is the default and the only behaviour any organization has ever had.sellingPartner— a limited organization. It is intended to keep normal membership, roles, settings, notifications, delegated invitations, the delegated catalog, member ordering, approval/delivery, returns, and partner-scoped sales, and to hold no events of its own and no Shared Events participation.
Current scope (#369, #373-#384). The mode itself — the field, the Super-Admin control surface, and the transition rules that decide whether a mode change is allowed — landed in #369. Route-level enforcement arrived one capability at a time; event creation (#373), event update (#374), the event sales lock (#375), event soft-delete and restore (#376), owned-event external sales (#377), the external-sales ledger (#378), owner-side sales-delegation administration (#379), Shared Event creation and joining (#380) and the owner/admin force-record-sold override (#384) are the enforced capabilities, with Shared Event discovery (#382) no longer offering what those guards refuse. With #384 every owner-only capability reserved in #369 is enforced at a route;
sellingPartneris now an active restriction rather than a record of intent. The other guarantee already in force is the downgrade gate below, which refuses to put an organization intosellingPartnerwhile owner-side state exists.Enforced today:
POST /api/management/events,PUT /api/management/events/{id},PUT /api/management/events/{id}/lock,DELETE /api/management/events/{id},PATCH /api/management/events/{id}/restore,PUT /api/management/events/{eventId}/showtimes/{showtimeId}/external, all four external-sales ledger mutations (POST /api/management/events/{eventId}/showtimes/{showtimeId}/external-sales,PUTandDELETE /api/management/external-sales/{id}, andPOST /api/management/external-sales/{id}/correction) and all three owner-side sales-delegation routes (POST,GETandPOST .../{delegationId}/revokeunder/api/organizations/{organizationId}/events/{eventId}/sales-delegations) and the Shared Event create/join routes (POST /api/shared-events,POST /api/shared-events/{sharedEventId}/participants,POST /api/shared-events/{sharedEventId}/participants/{orgId}/accept,POST /api/shared-event-invitations/pending/acceptandPOST /api/shared-event-invitations/accept) and the force-record-sold override (POST /api/orders/{id}/force-record-sold) refuse asellingPartnerorganization with 403 anderrorCodeORG_MODE_RESTRICTED, before any event, ticket-type, external-sales, delegation, Shared Event or order document is written — on update that means before the ticket-type create/update/delete pass as well as before the event document itself is replaced, on the sales lock it means before the lock state is written, before members are notified, and before the new state is propagated to any partner catalog, on delete and restore it means before thedeletedAttombstone is stamped or stripped and before any partner catalog is blocked or re-published, on owned-event external sales it means before the event is read and before the request body is parsed, on the ledger it means before the ledger is read, before the request body is parsed, and before any row, correction-state document, immutable correction audit row or Shared Event capacity adjustment is written, and on sales delegations it means before the rate-limit bucket is read or written, before the owner event is read, before the request body is parsed, and before any invitation token,Event.salesDelegations[]transition, reverse-index write, catalog projection or partner notification, and on Shared Events it means before the Shared Event root is read or created, before a roster entry is appended, before an invitation token is redeemed, and before the accept saga materializes an Event and its ticket types into the joining organization's partition — and, for the organizer/acting organization and at all three invitation-acceptance routes, before the rate-limit bucket is read or written as well; only the invitee check, which cannot be made before the request body names the organization being added, runs after the organizer's bucket — and on force-record-sold it means before the request body is read and before the order point read, the event read, the Shared Event capacity reservation, the capacity-ledger movement, the order replace, the immutableoversale_recordedaudit entry and the admin oversale notification. The decision comes from an authoritative read of the target organization on every request, never from the session or the access token — so a Platform Super Admin who selected or is impersonating inside the organization is denied identically, and a stale token cannot outlive a mode change. A Super Admin upgrades the organization tofullfirst. If that organization read fails, the request fails closed (transient 5xx, or 404 for a missing organization); it is never treated as allowed.On update, on the sales lock, on delete/restore and on external sales the restriction is decided before the event is read, so a restricted organization receives the same 403 whether or not the target event exists, and whatever kind of event it is. The sales lock is one capability in both directions: a restricted organization can neither open nor close sales. Soft-delete and restore are enforced symmetrically for the same reason — the downgrade gate below already refuses to enter
sellingPartnerwhile a live or soft-deleted owned event exists, so a limited organization has no legitimate cleanup case in either direction, and the 403 does not depend on whether the target event carries adeletedAt. External sales are two different capabilities.events.updateExternalSales(#377) covers the owned event's per-showtimeexternalSold/externalNotefigure, which feeds the capacity and remaining-seat arithmetic; the 403 does not depend on the showtime existing or on the payload being valid.externalSales.correction(#378) covers the separate external-sales ledger — the individual sale rows and the administrative correction protocol that adjusts them. One capability gates all four ledger mutations, because create, update, delete and correction are four entry points to the same owner-side ledger: guarding only the correction verb would leave the same end state reachable by writing, rewriting or dropping the underlying row. The ledger 403 does not depend on the sale or the event existing, so a restricted organization cannot use the 404 to probe which ids exist, and the twoGETlisting routes stay open. Sales delegations are three capabilities on the owner side only (events.salesDelegations.invite,.list,.revoke, #379): theGETis restricted along with the two mutations because it is the owner's roster of who may sell its tickets and because it writes — it self-heals the reverse index and re-materializes delegated catalog projections. The partner half of the same relationship is untouched: accepting or declining an inbound invitation, the delegated catalog, member self-ordering, approval/delivery, sale/return reporting, order history and partner-scoped sales reporting all remain available to asellingPartnerorganization, and afullowner may still invite, list and revoke asellingPartnerpartner — the invitee's mode is never read. Delegated-catalog projections and Shared Event projections were already immutable — and, for a cascaded Shared Event block, already un-togglable — through these routes and stay so; for afullorganization their existing, more specific denials, and the existing 404/400/409 semantics, along with the correction protocol's optimistic-concurrency retries and audit rows, are unchanged.Shared Events are two capabilities, on two different organizations (
sharedEvents.createandsharedEvents.join, #380).createis keyed on the organizer — the organization installed as theorganizerparticipant and the owner of the Shared Event — andjoinon the organization being added to, or joining, a roster.POST /api/shared-eventsandPOST .../participantscan change both at once, so both are checked: afullorganizer is refused when it names asellingPartnerinvitee, and the 403 then carriessharedEvents.join. Joining is guarded at all three doors — roster acceptance, in-app invitation acceptance and token-link acceptance — because each of them ends in the same roster append and the same Event projection in the joining organization's partition; guarding only one would leave the others reachable. Super Admin cannot bypass it:POST .../participantsalready requires super-admin authority, and the mode answer still comes from the organization document being added, so a neutral Super Admin creating a Shared Event with asellingPartnerinvitee is refused too. The invitation anti-oracle contract is unchanged — the denial is keyed only on the caller's own organization and fires before any invitation or Shared Event read, so invalid, expired, used, revoked and cross-user invitations all keep their uniform jittered 404. Exits stay open on purpose: declining, leaving and being removed are not restricted, so a mode change can never trap an organization in a Shared Event; neither are the read routes a pre-existing participant needs, or the owner's ongoing administration of a roster it already has. The delegated selling-partner flows are a different feature and remain fully available.The force-record-sold override is one capability on exactly one route (
orders.forceRecordSold, #384), and it is deliberately the only restricted endpoint in the whole order lifecycle. It is not a sale: it is the owner's explicit override of the venue capacity of an event it owns — Admin/Super-Admin only, it bypasses the ceiling, still moves the capacity ledger, writes an immutableoversale_recordedaudit entry, stamps the administrative-correction fields a later reconciliation freezes into the invoice basis, and alerts every admin. AsellingPartnerorganization owns no events, so it has no legitimate caller. The 403 does not depend on the order or the event existing, on the payload being valid, or on the order's status, so the existing 404/400 answers cannot be used to probe them and the idempotent no-change replay is unreachable. Everything else in the order lifecycle stays open and unchanged: create, approve, deliver, cancel, transfer, archive,report-sold,returnandapprove-reconciliation, and the delegated partner endpoints —report-sold,report-returned, member self-ordering, approval/delivery and order history — remain fully available. The shared capacity-consuming lifecycle code these two sides have in common carries no capability check at all; the restriction lives only at the force-record route.Shared Event discovery answers the same policy as data, not as a denial (#382).
GET /api/shared-event-organizations/searchleavessellingPartnerorganizations out of the participant picker in both of its modes, so a Super Admin cannot select one as a participant and is not offered a roster the create/invite routes would then refuse;GET /api/shared-event-capabilities/createkeeps its stable 200{ canCreateSharedEvents, reason }contract and answersfalsewithorganization_mode_restrictedfor a selectedsellingPartnerorganization. Neither becomes a 403: a picker and a capability probe describe a door rather than open one, and a probe that started throwing would break every caller that only asks in order to decide whether to render a control.organization_mode_restrictedis its own reason rather than a reuse oforganization_not_allowed, because an allowlist decision and the organization's own mode are different facts with different remedies. The search reads the mode as part of the organization query it already issues — no per-result lookup — and never returns it; the probe spends one point read, and only on the path that was going to answertrue, so a globally disabled or non-allowlisted organization keeps its exact existing reason at no extra cost. Both fail closed: a failed query or organization read surfaces as the route's existing 5xx/404, never as an unfiltered page, never ascanCreateSharedEvents: true, and never as the restricted answer — so a read failure cannot be mistaken for, or disclose, a restriction. The same search endpoint also backs delegated selling-partner discovery, where asellingPartnerorganization is precisely the intended target, so the exclusion is selected per call withpurpose(see the Shared Events endpoint table); the delegated routes themselves are untouched, and a Super Admin may still invite asellingPartnerorganization as a selling partner.
Organization.organizationMode is absent on every organization created before this feature, and an absent value means full. full is always stored as an absent field, so full has exactly one representation.
Who can change it: Platform Super Admins only, either at creation (POST /api/organizations accepts an optional organizationMode) or through PUT /api/organizations/{id}/mode. Organization Admins cannot change the mode: PUT /api/organizations/{id} rejects an organizationMode field with an explicit 400 rather than silently ignoring it.
PUT /api/organizations/{id}/mode accepts:
{
"organizationMode": "sellingPartner"
}Transitions:
sellingPartner→full— always allowed.full→sellingPartner— allowed only when the organization holds no owner-side state. The server verifies, authoritatively, that it owns no events (including soft-deleted ones, which are restorable), has no Shared Event participation, and is not still referenced as the owner of an active or invited sales delegation.- Re-asserting the current mode returns 200 with the unchanged organization: no write, no audit event.
Error conditions:
- 400 — Body missing
organizationMode, or carrying a value other thanfullorsellingPartner - 403 — Caller is not a Platform Super Admin
- 404 — Organization not found or soft-deleted
- 409 (
ORG_MODE_DOWNGRADE_BLOCKED) — Owner-side dependencies still exist; the response message names them (ownedEvents,sharedEventParticipation,outboundSalesDelegations) - 409 (
ORG_MODE_TRANSITION_RACED) — Another request changed the mode while this one was being decided. The mode was not changed and nothing was written; the response message names the mode that is now current. Re-read the organization and retry if the change is still wanted. - 503 (
ORG_MODE_DEPENDENCY_CHECK_FAILED) — A dependency check could not be completed. The mode was not changed; retry. A check that cannot answer is never treated as "no dependency".
Audit: a real transition emits a structured organization.mode_changed telemetry event exactly once, carrying the organization ID, the previous and new mode, and the acting Super Admin's user ID.
Notifications
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/notifications | List current user's notifications | Member+ |
| POST | /api/notifications/{id}/read | Mark one notification as read | Member+ |
| POST | /api/notifications/read-all | Mark all notifications as read | Member+ |
| DELETE | /api/notifications/{id} | Delete a notification | Member+ |
All notification endpoints require ?organizationId={organizationId}. Notifications are scoped to the requesting user — you can only read and delete your own notifications. Returns the 50 most recent notifications ordered by createdAt DESC.
Reports
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/management/reports | Get report data (all tabs) | Treasurer+ |
| GET | /api/management/reports/export | Export report as CSV | Treasurer+ |
Query parameters for GET /api/management/reports:
| Parameter | Values | Description |
|---|---|---|
type | members, choirs, events, types, ticketmaster, all | Report tab to load |
organizationId | organization ID | Required |
eventId | event ID | Optional filter |
Printed-order financial reporting is sold-only. Pending and approved orders contribute zero. Delivered and awaiting-reconciliation orders use validated reported sold lines when available; settled orders use the validated frozen invoice basis. Returned or outstanding tickets contribute no revenue or fee, and invalid or incomplete detail never falls back to TicketOrder.totalAmount.
Query parameters for export:
| Parameter | Values | Description |
|---|---|---|
exportType | transactions, members, showtimes, orders | What to export |
format | csv | File format (only CSV currently) |
organizationId | organization ID | Required |
The transactions, members, and showtimes CSV exports use the same sold-only printed-order quantities, order-time prices, and fee quantities as the report response.
Management
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/management/cleanup | Delete all data (dev/test only) | Super Admin |
| GET | /api/management/events/{eventId}/external-sales | List external sales for event | Ticket Manager+ |
| GET | /api/management/events/{eventId}/showtimes/{showtimeId}/external-sales | List external sales by showtime | Ticket Manager+ |
| POST | /api/management/events/{eventId}/showtimes/{showtimeId}/external-sales | Create external sale entry | Ticket Manager+ |
| PUT | /api/management/external-sales/{id} | Update external sale | Ticket Manager+ |
| DELETE | /api/management/external-sales/{id} | Delete external sale | Ticket Manager+ |
Management Cleanup
The POST /api/management/cleanup endpoint is only available when E2E_TEST_MODE=true and requires Super Admin authentication. Pass ?confirm=yes to actually delete all data. Warning: This will delete ALL organizations, events, registrations, ticket types, and other application data. Only use in development/test environments.
Next: Environment Variables · See also: Architecture · Data Model