Skip to content

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:

json
{ "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

MethodEndpointDescriptionAuth
POST/api/auth/magic-linkRequest login emailPublic
POST/api/auth/verifyVerify magic link tokenPublic
POST/api/auth/verify-codeVerify 6-digit code from emailPublic
GET/api/auth/meGet current userRequired

Events

MethodEndpointDescriptionAuth
GET/api/eventsList all eventsRequired
GET/api/events-activeGet active eventRequired
GET/api/events-overviewGet events with statsRequired
GET/api/events/{id}Get event detailsRequired
POST/api/management/eventsCreate event 1Admin
PUT/api/management/events/{id}Update event 1Admin
DELETE/api/management/events/{id}Soft-delete event 1Admin
PATCH/api/management/events/{id}/restoreRestore soft-deleted event 1Admin
PUT/api/management/events/{id}/lockToggle sales lock 1Admin
PUT/api/management/events/{eventId}/showtimes/{showtimeId}/externalUpdate a showtime's external sales 1Admin

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

MethodEndpointDescriptionAuth
GET/api/shared-event-capabilities/createCheck whether the current organization can create Shared EventsOrganization Admin
POST/api/shared-eventsCreate a draft Shared Event 4Organization Admin or Super Admin
GET/api/shared-eventsList Shared Events for the current organization; scope=all is Super Admin onlyParticipant Member or Super Admin
GET/api/shared-events/{sharedEventId}Get the caller-authorized Shared Event viewParticipant Member, invited viewer, or Super Admin
GET/api/shared-events/{sharedEventId}/capacityRead authoritative Shared Event capacityParticipant Member or Super Admin
GET/api/shared-event-invitations/pendingList invitations available to organizations the caller administersAuthenticated Organization Admin
POST/api/shared-event-invitations/pending/acceptAccept an in-app invitation 4Admin of accepting organization
GET/api/shared-event-invitations/previewPreview a token invitation without exposing invalid-token detailsAuthenticated
POST/api/shared-event-invitations/acceptAccept a token invitation 4Admin of accepting organization
POST/api/shared-events/{sharedEventId}/invitation-linksCreate an invitation link and optionally attempt email deliverymanageParticipants and Organization Admin
GET/api/shared-events/{sharedEventId}/invitation-linksList invitation metadata; token hashes are never returnedmanageParticipants
DELETE/api/shared-events/{sharedEventId}/invitation-links/{linkId}Revoke an invitation linkmanageParticipants and Organization Admin
POST/api/shared-events/{sharedEventId}/participants/{orgId}/acceptAccept a roster invitation 4Admin of target organization
POST/api/shared-events/{sharedEventId}/participants/{orgId}/declineDecline a roster invitationAdmin of target organization
POST/api/shared-events/{sharedEventId}/participants/{orgId}/leaveLeave a Shared EventAdmin 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

MethodEndpointDescriptionAuth
PATCH/api/shared-events/{sharedEventId}Edit canonical details or pricingOrganization Admin with manageSharedDetails or managePricing
PATCH/api/shared-events/{sharedEventId}/capacityEdit pool capacitySuper Admin
DELETE/api/shared-events/{sharedEventId}Retire a draft Shared Event safelySuper Admin
POST/api/shared-events/{sharedEventId}/participantsAdd an organization directly to the roster 4Super Admin
DELETE/api/shared-events/{sharedEventId}/participants/{orgId}Remove a participantOrganization Admin with manageParticipants
PATCH/api/shared-events/{sharedEventId}/lifecycleMove the event between allowed lifecycle statesOrganization Admin with manageSharedDetails
PATCH/api/shared-events/{sharedEventId}/participants/{orgId}/quotaUpdate one participant quotamanageCapacity
PATCH/api/shared-events/{sharedEventId}/quotasUpdate participant quotas in bulkmanageCapacity
POST/api/shared-events/{sharedEventId}/capacity-policyChange the capacity policy through the guarded transition workflowmanageCapacity
GET/api/shared-events/{sharedEventId}/participants/{orgId}/usersList eligible users for owner or grant administrationOrganization Admin with manageOwners, or Super Admin
PUT/api/shared-events/{sharedEventId}/grants/{granteeUserId}Issue or update a named-user capability grantOrganization Admin with manageOwners
DELETE/api/shared-events/{sharedEventId}/grants/{granteeUserId}Revoke a named-user grantOrganization Admin with manageOwners
GET/api/shared-events/{sharedEventId}/auditRead the paginated Shared Event audit feedAuthorized participant or Super Admin

Reporting, channels, and operational endpoints

MethodEndpointDescriptionAuth
GET/api/shared-events/{sharedEventId}/reports/combinedGet the combined event reportOwner, viewCombinedReports, or Super Admin
GET/api/shared-events/{sharedEventId}/reports/participantGet a participant report; defaults to the caller's organizationParticipant for own organization; owner or Super Admin for an allowed target
GET/api/shared-events/{sharedEventId}/external-sales-channelsList configured external sales channels with protected references redactedParticipant Member, invited organization member, or Super Admin
GET/api/shared-events/{sharedEventId}/external-sales-channels/{source}Get one external sales channel with protected references redactedParticipant Member, invited organization member, or Super Admin
PUT/api/shared-events/{sharedEventId}/external-sales-channels/{source}Assign or update the responsible participant organizationmanageExternalSales and Organization Admin
DELETE/api/shared-events/{sharedEventId}/external-sales-channels/{source}Disable a channel without deleting historical attributionmanageExternalSales and Organization Admin
GET/api/shared-event-organizations/searchSearch active organizations for direct roster administration 5Super Admin
POST/api/shared-events/{sharedEventId}/operation-statusRefresh recovery status and advance queued projection workAuthorized participant or Super Admin
POST/api/shared-events/{sharedEventId}/sync-projectionsRequest repair or synchronization of organization projectionsOrganization 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 parameterValuesDefaultEffect
purposesharedEvent, delegatedSalessharedEventsharedEvent 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

MethodEndpointDescriptionAuth
POST/api/organizations/{organizationId}/events/{eventId}/sales-delegationsInvite a partner organization to sell tickets for this event 3Owner Organization Admin+
GET/api/organizations/{organizationId}/events/{eventId}/sales-delegationsList delegations for this event 3Owner Organization Admin+
POST/api/organizations/{organizationId}/events/{eventId}/sales-delegations/{delegationId}/revokeRevoke an invited or active delegation 3Owner 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-delegationsdiscovery 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:

FieldTypeWho may send itDescription
partnerAdminEmailstring (email)Any callerEmail 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.
partnerOrganizationIdstringSuper Admin onlyA 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.
json
{ "partnerAdminEmail": "admin@partner-choir.example" }
json
{ "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:

json
{
  "success": true,
  "data": {
    "delegation": { "id": "...", "status": "invited", "partnerOrganizationId": "org-id", "...": "..." },
    "invitationLink": "https://app.example/sales-delegations/accept?token=...",
    "reverseIndexSync": "synced"
  }
}
  • invitationLink embeds 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 invited or active delegation 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 partnerAdminEmail and partnerOrganizationId present, or neither present (schema validation failure).
  • 400partnerOrganizationId resolves to the caller's own organization (Cannot delegate sales to your own organization), or to a Shared Event projection.
  • 403partnerOrganizationId sent 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.
  • 404partnerAdminEmail could 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

MethodEndpointDescriptionAuth
POST/api/sales-delegations/acceptAccept an invitation using the one-time invitation link's tokenPartner Organization Admin+
POST/api/organizations/{partnerOrganizationId}/sales-delegations/{delegationId}/declineDecline a pending invitationPartner 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

MethodEndpointDescriptionAuth
GET/api/organizations/{partnerOrganizationId}/delegated-eventsList events currently delegated to this organizationAny approved member of the partner organization
GET/api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/sales-contextGet showtimes, ticket types, and a capacity hint for sellingTicket Manager+
POST/api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/selfMember self-service: create a pending delegated order for the authenticated callerAny approved member of the partner organization
POST/api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/{orderId}/cancelCancel a pending delegated order — by the buyer themselves, or by the partner's own Ticket Manager+ on the buyer's behalfBuyer, or Ticket Manager+
GET/api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/queuePartner's approval queue for its own members' self-service orders, filterable by pending or approved statusTicket Manager+
POST/api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/{orderId}/approveApprove a pending self-service orderTicket Manager+
POST/api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/{orderId}/deliverMark an approved order deliveredTicket Manager+
POST/api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/{orderId}/approve-and-deliverApprove and deliver a pending order in one stepTicket Manager+
GET/api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/ordersList this partner organization's own orders for the eventTicket Manager+
POST/api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/{orderId}/report-soldReport sold quantity on a delegated orderTicket Manager+, or the buyer for their own order
POST/api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/orders/{orderId}/report-returnedReport returned quantity on a delegated orderTicket Manager+, or the buyer for their own order
POST/api/organizations/{partnerOrganizationId}/delegated-events/{eventId}/ordersDeprecated, 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 stepTicket Manager+
  • GET .../delegated-events returns only refs whose denormalized status is "active" — invited, declined, and revoked delegations never appear in this list.
  • GET .../sales-context returns 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/self requires an active delegation and the same live checks as any other delegated write. It is unconditionally closed off the ordinary POST /api/orders route — 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. When DELEGATED_MEMBER_ORDERING_ENABLED is off, this endpoint returns HTTP 503 with error code DELEGATED_MEMBER_ORDERING_UNAVAILABLE and creates nothing.
  • POST .../orders/{orderId}/cancel only transitions a pending order 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/queue and the approve / deliver / approve-and-deliver actions 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 .../orders endpoint 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 already status: "delivered" with soldQuantity: 0 and returnedQuantity: 0. A capacity conflict at submission time is rejected with HTTP 409 CAPACITY_EXCEEDED and creates nothing.
  • GET .../orders is scoped server-side to soldByOrganizationId === 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-sold and POST .../report-returned both 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

MethodEndpointDescriptionAuth
GET/api/ordersList orders (with filters)Ticket Manager+
POST/api/ordersCreate order requestMember+
GET/api/orders/{id}Get order detailsTicket Manager+
POST/api/orders/{id}/approveApprove orderTicket Manager+
POST/api/orders/{id}/deliverMark order deliveredTicket Manager+
POST/api/orders/{id}/cancelCancel orderTicket Manager+
DELETE/api/orders/{id}Delete orderTicket Manager+
PATCH/api/orders/{id}/archiveArchive or unarchive orderTicket Manager+
POST/api/orders/archive/previewPreview eligible completed orders for one eventTicket Manager+
POST/api/orders/archiveArchive one batch of eligible completed orders for one eventTicket Manager+
PATCH/api/orders/{id}/admin-noteUpdate admin note on orderTicket Manager+
POST/api/orders/{id}/transferTransfer all or part of an order to another memberTicket Manager+
GET/api/me/ordersGet current user's ordersMember+

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

MethodEndpointDescriptionAuth
POST/api/orders/{id}/report-soldRecord the new cumulative number sold from a delivered orderOrder owner or Admin
POST/api/orders/{id}/returnRecord an incremental return of unsold printed ticketsOrder owner or Ticket Manager+
POST/api/orders/{id}/force-record-soldForce-record a sale that exceeds the remaining venue capacity 1Admin
POST/api/orders/{id}/approve-reconciliationApprove a fully accounted order and freeze its invoice basisTicket 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:

json
{
  "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:

json
{
  "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.

MethodEndpointDescriptionAuth
POST/api/orders/{id}/transferTransfer all or part of an order to another memberTicket Manager+

POST /api/orders/{id}/transfer accepts:

json
{
  "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 } with quantity >= 1) — Exact per-ticket-type quantities to transfer. Only list the ticket types actually moving.
  • quantity (integer >= 1) — Alternative to tickets: a bare aggregate quantity, proportionally split across the order's ticket-type lines. At least one of quantity or tickets is required; use one form per request. If both are present, tickets is authoritative and quantity is 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):

json
{
  "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 === resultOrderId and sourceRemainingQuantity is always 0.
  • sourceHistoryTab, sourceActionUrl — the sender's History tab (active | delivered | settled | cancelled) and deep link. sourceActionUrl is 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. resultQuantity always equals quantity.
  • 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, neither quantity nor tickets provided, a per-type quantity below 1), recipient not found in the organization, order status other than approved/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. Response data includes requestedQuantity and availableQuantity (and ticketTypeId for 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 same transferRequestId was 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.

MethodEndpointDescriptionAuth
GET/api/allocationsList allocationsTicket Manager+
POST/api/allocationsCreate/update allocationTicket Manager+
DELETE/api/allocations/{id}Delete allocationTicket Manager+
GET/api/allocations/membersList members with allocation summaryTicket Manager+
GET/api/my-allocationsGet member's own allocationsMember+

Commitments / Pledges

MethodEndpointDescriptionAuth
GET/api/events/{eventId}/my-pledgeGet member's own pledge for eventMember+
POST/api/events/{eventId}/my-pledgeSubmit or update commitment pledgeMember+
GET/api/events/{eventId}/pledge-summaryGet aggregate pledge summaryMember+
GET/api/management/events/{eventId}/pledgesList all pledges for eventTicket Manager+
PUT/api/management/events/{eventId}/commitmentOpen/close/convert commitment roundAdmin

External Sales

MethodEndpointDescriptionAuth
GET/api/management/events/{eventId}/external-salesList external sales for eventTicket Manager+
GET/api/management/events/{eventId}/showtimes/{showtimeId}/external-salesList external sales by showtimeTicket Manager+
POST/api/management/events/{eventId}/showtimes/{showtimeId}/external-salesCreate external sale entry 2Ticket Manager+
PUT/api/management/external-sales/{id}Update external sale 2Ticket Manager+
DELETE/api/management/external-sales/{id}Delete external sale 2Ticket Manager+
POST/api/management/external-sales/{id}/correctionApply an administrative correction 2Admin

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

MethodEndpointDescriptionAuth
GET/api/events/{eventId}/free-ticketsList free ticketsTicket Manager+
POST/api/events/{eventId}/free-ticketsCreate free ticket entryTicket Manager+
PUT/api/events/{eventId}/free-tickets/{id}Update a free ticket entryTicket Manager+
DELETE/api/events/{eventId}/free-tickets/{id}Delete free ticketTicket Manager+
GET/api/events/{eventId}/capacity-summaryGet capacity summary for eventRequired

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)

MethodEndpointDescriptionAuth
POST/api/registrationsCreate 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/historyGet user's sales historyMember+
GET/api/me/statsGet user's statsMember+

Admin / Dashboard

MethodEndpointDescriptionAuth
GET/api/management/dashboard/{eventId}Get dashboard statsTicket Manager+
GET/api/management/dashboard/{eventId}/exportExport CSVTicket Manager+
GET/api/management/dashboard/{eventId}/export-excelExport ExcelTicket Manager+
PUT/api/management/members/{userId}/invoice/{eventId}Issue or clear invoice state for a member and eventTicket Manager+
PUT/api/management/registrations/{id}/invoiceMark one registration as invoicedTicket 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

MethodEndpointDescriptionAuth
POST/api/organizations/{organizationId}/invitationsCreate invitation codeAdmin
GET/api/organizations/{organizationId}/invitationsList invitationsAdmin
DELETE/api/organizations/{organizationId}/invitations/{code}Delete/revoke invitationAdmin
GET/api/join/{code}Validate invitation codePublic
POST/api/join/{code}Accept invitation and join organizationPublic

Super Admin

MethodEndpointDescriptionAuth
GET/api/organizationsList all organizationsSuper Admin
POST/api/organizationsCreate organizationSuper Admin
GET/api/organizations/{id}Get organization detailsSuper Admin
PUT/api/organizations/{id}Update organization (see Organization Updates)Org Admin or Super Admin
PUT/api/organizations/{id}/modeChange organization capability mode (see Organization Capability Mode)Super Admin
DELETE/api/organizations/{id}Delete organizationSuper Admin
POST/api/organizations/{id}/members/importImport members via CSVSuper Admin
GET/api/organizations/{id}/membersList organization membersSuper Admin
POST/api/organizations/{id}/membersAdd member to organizationSuper Admin
PUT/api/organizations/{id}/members/{memberId}Update member roleSuper Admin
DELETE/api/organizations/{id}/members/{memberId}Remove member from organizationSuper Admin
GET/api/usersList all usersSuper Admin
GET/api/users/{id}Get user detailsSuper Admin
PUT/api/users/{id}Update userSuper Admin
PUT/api/users/{id}/super-adminToggle super admin statusSuper Admin
POST/api/users/importImport users via CSVSuper Admin
DELETE/api/users/bulk-deleteBulk delete usersSuper Admin
POST/api/users/bulk-assign-organizationBulk assign users to organizationSuper Admin
POST/api/impersonation/auditLog impersonation eventSuper Admin
GET/api/impersonation/auditGet impersonation logsSuper 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:

json
{
  "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. Omitting name leaves the current name unchanged.
  • description (optional, string, max 2,000 characters after trimming) — The organization's description. Omitting description leaves 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), never null or 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):

json
{
  "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 name and description (plus a few other descriptive fields such as type and logo). The organization id (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 name is omitted, the request still returns HTTP 200 with the current organization, updatedAt/updatedBy are still refreshed, but no rename is recorded — see "What does not change" below. The same applies independently to description: 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 snapshotsInvitation.organizationName is 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 (name present but empty/whitespace-only after trimming, or exceeds 255 characters; or description exceeds 2,000 characters after trimming; or the body carries organizationMode, 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; sellingPartner is 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 into sellingPartner while 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, PUT and DELETE /api/management/external-sales/{id}, and POST /api/management/external-sales/{id}/correction) and all three owner-side sales-delegation routes (POST, GET and POST .../{delegationId}/revoke under /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/accept and POST /api/shared-event-invitations/accept) and the force-record-sold override (POST /api/orders/{id}/force-record-sold) refuse a sellingPartner organization with 403 and errorCode ORG_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 the deletedAt tombstone 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 immutable oversale_recorded audit 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 to full first. 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 sellingPartner while 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 a deletedAt. External sales are two different capabilities. events.updateExternalSales (#377) covers the owned event's per-showtime externalSold/externalNote figure, 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 two GET listing routes stay open. Sales delegations are three capabilities on the owner side only (events.salesDelegations.invite, .list, .revoke, #379): the GET is 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 a sellingPartner organization, and a full owner may still invite, list and revoke a sellingPartner partner — 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 a full organization 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.create and sharedEvents.join, #380). create is keyed on the organizer — the organization installed as the organizer participant and the owner of the Shared Event — and join on the organization being added to, or joining, a roster. POST /api/shared-events and POST .../participants can change both at once, so both are checked: a full organizer is refused when it names a sellingPartner invitee, and the 403 then carries sharedEvents.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 .../participants already 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 a sellingPartner invitee 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 immutable oversale_recorded audit entry, stamps the administrative-correction fields a later reconciliation freezes into the invoice basis, and alerts every admin. A sellingPartner organization 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, return and approve-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/search leaves sellingPartner organizations 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/create keeps its stable 200 { canCreateSharedEvents, reason } contract and answers false with organization_mode_restricted for a selected sellingPartner organization. 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_restricted is its own reason rather than a reuse of organization_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 answer true, 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 as canCreateSharedEvents: 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 a sellingPartner organization is precisely the intended target, so the exclusion is selected per call with purpose (see the Shared Events endpoint table); the delegated routes themselves are untouched, and a Super Admin may still invite a sellingPartner organization 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:

json
{
  "organizationMode": "sellingPartner"
}

Transitions:

  • sellingPartnerfull — always allowed.
  • fullsellingPartner — 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 than full or sellingPartner
  • 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

MethodEndpointDescriptionAuth
GET/api/notificationsList current user's notificationsMember+
POST/api/notifications/{id}/readMark one notification as readMember+
POST/api/notifications/read-allMark all notifications as readMember+
DELETE/api/notifications/{id}Delete a notificationMember+

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

MethodEndpointDescriptionAuth
GET/api/management/reportsGet report data (all tabs)Treasurer+
GET/api/management/reports/exportExport report as CSVTreasurer+

Query parameters for GET /api/management/reports:

ParameterValuesDescription
typemembers, choirs, events, types, ticketmaster, allReport tab to load
organizationIdorganization IDRequired
eventIdevent IDOptional 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:

ParameterValuesDescription
exportTypetransactions, members, showtimes, ordersWhat to export
formatcsvFile format (only CSV currently)
organizationIdorganization IDRequired

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

MethodEndpointDescriptionAuth
POST/api/management/cleanupDelete all data (dev/test only)Super Admin
GET/api/management/events/{eventId}/external-salesList external sales for eventTicket Manager+
GET/api/management/events/{eventId}/showtimes/{showtimeId}/external-salesList external sales by showtimeTicket Manager+
POST/api/management/events/{eventId}/showtimes/{showtimeId}/external-salesCreate external sale entryTicket Manager+
PUT/api/management/external-sales/{id}Update external saleTicket Manager+
DELETE/api/management/external-sales/{id}Delete external saleTicket 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

Built with VitePress