Skip to content

Auction Audience and Participation — Complete Frontend Integration

This is the canonical frontend contract for auction viewing, participation, join windows, audience counts, audit views, and the admission requirement for bidding. These contracts replace the development-only auction presence model. There are no compatibility aliases or legacy presence payloads.

Contract summary

Surface Audience identities joinedAt / durable lastSeenAt
Subscriber auction detail before persisted window opening Hidden; audience: null Hidden
Subscriber auction detail after opening, requester not joined Counts only; participants: null Hidden
Subscriber auction detail after opening, requester joined Paginated durable participant roster Included and normalized against current presence
Company/admin auction overview Paginated staff audience roster Included from the existing participant query
Company/admin audience-session audit Durable participation records plus connection sessions Included on participation records
Subscriber auction.state.snapshot Counts only Own joinedAt remains under currentUser; participant lastSeenAt is REST-only
Staff auction.state.snapshot allSubscribers and onlineSubscribers Included using data already loaded for the snapshot
Participation join acknowledgement Caller only Included; lastSeenAt is normally null on first admission
Staff auction.participation.joined Newly admitted participant Included in the durable event
auction.audience.snapshot Counts only Not included
Public and staff auction.audience.changed Affected realtime participant projection + counts No durable joinedAt; see Audience snapshots and changes

No additional REST endpoint is required. The subscriber roster is part of GET /v2/subscribers/me/auctions/:auctionId, and the existing staff and audit endpoints carry the corresponding staff fields.

1. Frontend state model

Treat these as separate states:

State Meaning Payment Persists after disconnect
VIEWER Eligible subscriber currently watching Not required No
PARTICIPANT Subscriber explicitly admitted to the auction Required when first joining Yes, until auction completion
Bidder Participant with at least one accepted bid Admission already required Yes

Joining never creates a bid. A participant may leave without bidding. Durable admission survives unsubscribe, socket loss, rescheduling, join-window closure, and payment reversal. A live-auction disqualification still blocks bidding.

Audience counters always count distinct enrollment IDs, so multiple tabs do not inflate them:

interface AuctionAudienceCounts {
  viewerCount: number;
  joinedParticipantCount: number;
  onlineParticipantCount: number;
  bidderCount: number;
}
  • viewerCount: online eligible enrollments whose durable access mode is VIEWER.
  • joinedParticipantCount: all durable participant records, online or offline.
  • onlineParticipantCount: admitted enrollments online or marked floor-present.
  • bidderCount: admitted enrollments with at least one accepted bid.

Realtime and staff contracts retain the established name onlineParticipantCount. The subscriber detail REST response exposes the same value as audience.presentParticipantCount, because floor presence is included.

Subscriber audience snapshots contain counts only. Changed events now also contain the affected participant projection. The subscriber REST detail response conditionally exposes durable participant identities after the join window has opened, as described below.

2. Shared REST conventions

All endpoints require a bearer access token. Subscriber endpoints require the SUBSCRIBER role. Company endpoints require COMPANY; admin endpoints require SUPERADMIN. Company access is limited to auctions owned by that company.

Successful REST responses use:

interface SuccessResponse<T> {
  status: 'success';
  code: number;
  message: string;
  data: T;
  error: null;
}

interface PaginatedResponse<T> {
  items: T[];
  count: number;
  pageNumber: number;
  pageSize: number;
  totalPages: number;
  hasNextPage: boolean;
  hasPreviousPage: boolean;
}

All timestamps are ISO-8601 strings in UTC. Nullable timestamps are returned as null, not omitted.

3. Subscriber auction detail

Request

GET /v2/subscribers/me/auctions/:auctionId
Authorization: Bearer <token>

Validation:

  • auctionId must be a valid CUID belonging to an auction visible to the authenticated subscriber.
  • participantPageNumber defaults to 1 and must be at least 1.
  • participantPageSize defaults to 10 and must be between 1 and 100.
  • An unpaid eligible subscriber can call this endpoint.

Changed response fields

The existing auction detail remains intact and adds these authoritative fields. enrollments contains one item per matching enrollment.

The former top-level viewerCount, joinedParticipantCount, onlineParticipantCount, and bidderCount fields are removed from this endpoint. Read all subscriber-detail audience values from the conditional audience object. No compatibility aliases are returned.

type AccessMode = 'VIEWER' | 'PARTICIPANT';
type JoinWindowStatus = 'SCHEDULED' | 'OPEN' | 'CLOSED';
type AuctionJoiningRule = 'BEFORE_START' | 'GRACE_PERIOD' | 'ANYTIME';

type JoinBlockedReason =
  | 'ALREADY_JOINED'
  | 'PAYMENT_REQUIRED'
  | 'PRIOR_WINNER'
  | 'DISQUALIFIED'
  | 'JOIN_WINDOW_NOT_OPEN'
  | 'JOIN_WINDOW_CLOSED'
  | null;

type BidBlockedReason =
  | 'AUCTION_PAUSED'
  | 'AUCTION_NOT_LIVE'
  | 'PARTICIPATION_REQUIRED'
  | 'PRIOR_WINNER'
  | 'DISQUALIFIED'
  | null;

type PrebidBlockedReason =
  | 'PREBID_DISABLED'
  | 'CYCLE_NOT_ACTIVE'
  | 'AUCTION_NOT_ACCEPTING_PREBIDS'
  | 'PREBID_NOT_OPEN'
  | 'PREBID_CLOSED'
  | 'PAYMENT_REQUIRED'
  | 'PRIOR_WINNER'
  | 'PREBID_DISQUALIFIED'
  | 'ACTIVE_PREBID_EXISTS'
  | null;

interface AuctionJoinWindow {
  status: JoinWindowStatus;
  version: number;
  opensAt: string | null;
  openedAt: string | null;
  closesAt: string | null;
  closedAt: string | null;
  joiningRule: AuctionJoiningRule;
}

interface AuctionCurrentUser {
  canViewDetails: boolean;
  canSubscribeRealtime: boolean;
  accessMode: AccessMode;
  hasJoined: boolean;
  joinedAt: string | null;
  canJoin: boolean;
  joinBlockedReason: JoinBlockedReason;
  isPrebidDisqualified: boolean;
  isLiveBidDisqualified: boolean;
  canPrebid: boolean;
  prebidBlockedReason: PrebidBlockedReason;
  canBid: boolean;
  bidBlockedReason: BidBlockedReason;
  hasAlreadyWonProgram: boolean;
  isPaymentCompletedForCycle: boolean;
}

interface SubscriberAuctionParticipant {
  subscriberId: string;
  enrolledSubscriberId: string;
  name: string | null;
  avatar: string | null;
  joinSource: 'ONLINE' | 'FLOOR';
  joinedAt: string;
  presenceStatus: 'PRESENT' | 'LEFT';
  lastSeenAt: string | null;
}

interface SubscriberAuctionAudience {
  viewerCount: number;
  joinedParticipantCount: number;
  presentParticipantCount: number;
  bidderCount: number;
  participants: PaginatedResponse<SubscriberAuctionParticipant> | null;
}

interface SubscriberAuctionDetailChanges {
  auctionDurationSeconds: number | null;
  settings: {
    auctionBidMode: 'DISCOUNT_INCREASING' | 'TOTAL_VALUE_DECREASING';
    auctionClosingMode: 'DURATION_MODE' | 'CALL_MODE';
    auctionDurationSeconds: number | null;
    auctionExtensionSeconds: number;
    auctionFirstCallSeconds: number;
    auctionSecondCallSeconds: number;
    auctionThirdCallSeconds: number;
    subscriberJoinGraceSeconds: number;
    allowStaffOfflineBids: boolean;
    auctionJoiningRule: AuctionJoiningRule;
  };
  joinWindow: AuctionJoinWindow;
  audience: SubscriberAuctionAudience | null;
  enrollments: Array<{
    enrolledSubscriberId: string;
    invoiceStatus: string | null;
    currentUser: AuctionCurrentUser;
    liveBids: Array<{
      id: string;
      amount: number;
      origin: 'ONLINE' | 'FLOOR' | 'AUTO';
      status: 'LEADING' | 'OUTBID';
      createdAt: string;
    }>;
    prebids: Array<{
      id: string;
      amount: number;
      status: string;
      createdAt: string;
    }>;
  }>;
}

Example:

{
  "status": "success",
  "code": 200,
  "message": "Auction fetched successfully.",
  "error": null,
  "data": {
    "auctionId": "cm123auction",
    "cycleId": "cm123cycle",
    "auctionStatus": "PENDING",
    "prebidPhase": "OPEN",
    "auctionStartAt": null,
    "auctionDurationSeconds": 90,
    "settings": {
      "auctionBidMode": "DISCOUNT_INCREASING",
      "auctionClosingMode": "DURATION_MODE",
      "auctionDurationSeconds": 90,
      "auctionExtensionSeconds": 30,
      "auctionFirstCallSeconds": 30,
      "auctionSecondCallSeconds": 30,
      "auctionThirdCallSeconds": 30,
      "subscriberJoinGraceSeconds": 120,
      "allowStaffOfflineBids": true,
      "auctionJoiningRule": "GRACE_PERIOD"
    },
    "joinWindow": {
      "status": "SCHEDULED",
      "version": 0,
      "opensAt": null,
      "openedAt": null,
      "closesAt": null,
      "closedAt": null,
      "joiningRule": "GRACE_PERIOD"
    },
    "audience": null,
    "enrollments": [
      {
        "enrolledSubscriberId": "cm123enrollment",
        "invoiceStatus": "PAID",
        "currentUser": {
          "canViewDetails": true,
          "canSubscribeRealtime": false,
          "accessMode": "VIEWER",
          "hasJoined": false,
          "joinedAt": null,
          "canJoin": false,
          "joinBlockedReason": "JOIN_WINDOW_NOT_OPEN",
          "isPrebidDisqualified": false,
          "isLiveBidDisqualified": false,
          "canPrebid": true,
          "prebidBlockedReason": null,
          "canBid": false,
          "bidBlockedReason": "AUCTION_NOT_LIVE",
          "hasAlreadyWonProgram": false,
          "isPaymentCompletedForCycle": true
        },
        "liveBids": [],
        "prebids": []
      }
    ]
  }
}

Use the server booleans to enable actions. Do not independently infer permission from timestamps or payment status.

audience is null until joinWindow.openedAt is persisted. After that point, all enrolled subscribers receive its counts. participants remains null for subscribers who have not joined; joined subscribers receive the requested page of all durable participants, including themselves. This remains available after the join window closes and after the auction ends. joinedAt is the durable admission timestamp. lastSeenAt is null while presenceStatus is PRESENT; when the status is LEFT, it is the latest present-to-left transition time, or null when the participant has never been observed present.

The fields are independent:

  • joinSource is immutable history describing how admission was created.
  • presenceStatus is current realtime state and may change repeatedly.
  • joinedAt never changes after admission.
  • lastSeenAt represents the latest transition from overall presence to absence. Overall presence includes either an active online connection or a staff-maintained floor-present overlay.
Participant lifecycle presenceStatus Public roster lastSeenAt
Joined but never observed present LEFT null
Online or floor-present PRESENT null
Last online connection closes, with no floor presence LEFT Departure timestamp
Staff marks floor absent, with no online connection LEFT Departure timestamp
Connection expires, auction closes, or presence is shut down LEFT Departure timestamp
Participant reconnects after leaving PRESENT null
Reconnected participant leaves again LEFT Newest departure timestamp

The durable stored timestamp is retained while a participant is present, but participant-facing roster responses deliberately mask it as null. This keeps the meaning of lastSeenAt unambiguous for the frontend.

Audience visibility matrix

Visibility is gated by persisted joinWindow.openedAt, not by the computed joinWindow.status:

State audience audience.participants
Window has never opened null Not available
Auction cancelled before opening null Not available
Window opened; requester has no participant record Counts object null
Window opened; any requester enrollment has joined Counts object Requested participant page
Window later closed Counts object Same joined/requester rule
Auction ended or completed Counts object Same joined/requester rule

For a joined requester, the roster includes every durable participant, including the requester. Its count therefore aligns with joinedParticipantCount even when the current page contains fewer items.

Example joined response fragment:

{
  "audience": {
    "viewerCount": 4,
    "joinedParticipantCount": 2,
    "presentParticipantCount": 1,
    "bidderCount": 1,
    "participants": {
      "items": [
        {
          "subscriberId": "subscriber-id",
          "enrolledSubscriberId": "enrollment-id",
          "name": "Annie Mohan",
          "avatar": null,
          "joinSource": "ONLINE",
          "joinedAt": "2026-09-06T09:51:00.000Z",
          "presenceStatus": "LEFT",
          "lastSeenAt": "2026-09-06T10:07:30.000Z"
        }
      ],
      "count": 2,
      "pageNumber": 1,
      "pageSize": 1,
      "totalPages": 2,
      "hasNextPage": true,
      "hasPreviousPage": false
    }
  }
}

For the same auction, a non-joined requester receives the four counts with "participants": null.

canViewDetails is true after this endpoint has authorized the subscriber's enrollment, including while the auction is PENDING. canSubscribeRealtime is true only for SCHEDULED, READY, LIVE, and PAUSED. Do not open a live auction socket or count a presence session while canSubscribeRealtime is false.

canPrebid is true only when the cycle is active, prebid is enabled and open, the auction is PENDING, SCHEDULED, or READY, payment is complete, the subscriber has not won the program, there is no PREBID disqualification, and there is no active prebid. prebidBlockedReason is the authoritative reason to display when it is false. A LIVE disqualification does not imply a PREBID disqualification.

Prebid placement request and response

POST /v2/subscribers/me/auctions/:auctionId/prebid
Authorization: Bearer <token>
Content-Type: application/json
interface PlacePrebidRequest {
  enrolledSubscriberId: string;
  amount: number;
  signatureAssetId?: string;
}

interface PlacePrebidResponse {
  status: 'success';
  code: 200;
  message: 'Prebid placed successfully.';
  error: null;
  data: {
    auctionId: string;
    cycleId: string;
    prebidId: string;
    enrolledSubscriberId: string;
    status: 'ACTIVE';
    documentSubmissionId: string | null;
  };
}

Validation and server rules:

  • auctionId, enrolledSubscriberId, and optional signatureAssetId must be CUIDs.
  • amount must be a valid JSON number and satisfy the auction bid mode, minimum, and total-amount rules.
  • signatureAssetId is required by the business flow when prebidDocumentRequired is true.
  • The authenticated subscriber must own the enrollment for this auction cycle.
  • The server rechecks every canPrebid condition inside a locked transaction using database time.
  • Only one active, non-deleted prebid is permitted per auction and enrollment.
  • canPrebid is advisory UI state. Handle a rejected POST because eligibility can change after the snapshot.

4. Subscriber live-room bootstrap

GET /v2/subscribers/me/cycles/:cycleId/auction/:enrolledSubscriberId
Authorization: Bearer <token>

Both path parameters are validated identifiers, and the enrollment must belong to the authenticated subscriber and cycle. The response includes joinWindow, all four audience counts, and this richer current-user shape:

interface LiveAuctionCurrentUser extends AuctionCurrentUser {
  subscriberId: string;
  enrolledSubscriberId: string;
  isLeadingBidder: boolean;
  activePrebid: object | null;
  latestBid: object | null;
  latestPrebid: object | null;
  disqualifications: object[];
}

This endpoint is the recommended initial REST request for a subscriber live-room screen. Connect WebSocket after it succeeds.

5. Staff auction overview and audience filters

GET /v2/companies/auctions/:auctionId?participantPageNumber=1&participantPageSize=20&audienceFilter=ONLINE_PARTICIPANTS
GET /v2/admin/auctions/:auctionId?participantPageNumber=1&participantPageSize=20&audienceFilter=ONLINE_PARTICIPANTS

Query validation:

Field Validation Default
bidPageNumber integer, minimum 1 1
bidPageSize integer, 1–100 10
prebidPageNumber integer, minimum 1 1
prebidPageSize integer, 1–100 10
participantPageNumber integer, minimum 1 1
participantPageSize integer, 1–100 10
audienceFilter enum below ALL_ELIGIBLE
audienceFilter Meaning
ALL_ELIGIBLE Every eligible enrollment
CURRENT_VIEWERS Currently online in VIEWER mode
ONLINE_PARTICIPANTS Admitted and online or floor-present
OFFLINE_PARTICIPANTS Admitted but currently offline
BIDDERS Admitted with an accepted bid
FLOOR_PARTICIPANTS Currently floor-present
ELIGIBLE_NEVER_VIEWED Eligible with no recorded audience session

The overview adds joinWindow and these participant fields:

interface StaffAuctionParticipant {
  subscriberId: string;
  enrolledSubscriberId: string;
  name: string | null;
  avatar: string | null;
  mobileNumber: string | null;
  countryCode: string | null;
  invoiceStatus: string | null;
  isPaymentCompletedForCycle: boolean;
  isOnline: boolean;
  accessMode: AccessMode | null;
  hasJoined: boolean;
  joinedAt: string | null;
  lastSeenAt: string | null;
  source: 'ONLINE' | 'FLOOR' | null;
  hasBid: boolean;
  hasPrebid: boolean;
  latestBidAmount: number | null;
  isDisqualified: boolean;
  hasWonCycle: boolean;
  wonCycleNumber: number | null;
}

participants is a deterministic paginated object whose items use this shape. lastSeenAt is null for an online entry and otherwise contains its durable last departure time, or null when no departure has been recorded.

6. Staff schedule and join-rule configuration

PATCH /v2/companies/auctions/:auctionId/schedule
PATCH /v2/admin/auctions/:auctionId/schedule
Content-Type: application/json
Authorization: Bearer <token>
interface UpdateAuctionScheduleRequest {
  auctionStartAt?: string | null;
  auctionDurationSeconds?: number | null;
  auctionBidMode?: 'DISCOUNT_INCREASING' | 'TOTAL_VALUE_DECREASING';
  auctionClosingMode?: 'DURATION_MODE' | 'CALL_MODE';
  auctionExtensionSeconds?: number | null;
  auctionFirstCallSeconds?: number | null;
  auctionSecondCallSeconds?: number | null;
  auctionThirdCallSeconds?: number | null;
  subscriberJoinGraceSeconds?: number | null;
  allowStaffOfflineBids?: boolean | null;
  auctionJoiningRule?: AuctionJoiningRule;
  reason?: string;
}

Validation:

  • At least one field other than reason must be supplied.
  • auctionStartAt must be null or a valid date-time today or in the future.
  • auctionDurationSeconds must be null or an integer of at least 1.
  • Extension, call, and grace durations must be null or non-negative integers.
  • reason, when present, is trimmed and must contain at least one character.
  • The enums must match the values shown above exactly.
  • The frontend may accept seconds or minutes, but must convert the chosen value to integer seconds before sending.
  • prebidMutabilityPolicy is not accepted by this endpoint.

Example request:

{
  "auctionStartAt": "2026-09-06T10:00:00.000Z",
  "auctionDurationSeconds": 90,
  "auctionExtensionSeconds": 30,
  "auctionJoiningRule": "GRACE_PERIOD",
  "subscriberJoinGraceSeconds": 120,
  "reason": "Configure the live round"
}

Response data:

interface UpdateAuctionScheduleResponse {
  auctionId: string;
  cycleId: string;
  stateVersion: number | null;
  status: string;
  prebidStartAt: string | null;
  prebidEndAt: string | null;
  auctionStartAt: string | null;
  auctionDurationSeconds: number | null;
  auctionEndAt: string | null;
  prebidPhase: string;
}

Every reschedule closes an open old join window with reason RESCHEDULED, increments joinWindow.version, and recomputes opensAt as start minus 600 seconds. Existing participants remain admitted.

7. Staff audience-session and participation audit

GET /v2/companies/auctions/:auctionId/audience-sessions
GET /v2/admin/auctions/:auctionId/audience-sessions

Optional query parameters:

Field Validation Default
enrolledSubscriberId valid CUID omitted
accessMode VIEWER or PARTICIPANT omitted
activeOnly boolean omitted
pageNumber integer, minimum 1 1
pageSize integer, 1–100 10

Response schema:

type DisconnectReason =
  | 'UNSUBSCRIBE'
  | 'SOCKET_CLOSE'
  | 'HEARTBEAT_EXPIRED'
  | 'AUCTION_ENDED'
  | 'SERVER_SHUTDOWN';

interface AuctionParticipationAudit {
  id: string;
  auctionId: string;
  cycleId: string;
  subscriberId: string;
  enrolledSubscriberId: string;
  subscriberName: string | null;
  subscriberAvatar: string | null;
  paidInvoiceId: string;
  joinRequestId: string;
  source: 'ONLINE' | 'FLOOR';
  joinWindowVersion: number;
  paymentVerifiedAt: string;
  policySnapshot: Record<string, unknown>;
  joinedAt: string;
  lastSeenAt: string | null;
}

interface AuctionAudienceSession {
  id: string;
  auctionId: string;
  cycleId: string;
  subscriberId: string;
  enrolledSubscriberId: string;
  subscriberName: string | null;
  subscriberAvatar: string | null;
  connectionId: string;
  initialAccessMode: AccessMode;
  currentAccessMode: AccessMode;
  connectedAt: string;
  lastSeenAt: string;
  becameParticipantAt: string | null;
  disconnectedAt: string | null;
  disconnectReason: DisconnectReason | null;
  ip: string | null;
  userAgent: string | null;
}

interface AuctionAudienceAuditResponse {
  auctionId: string;
  cycleId: string;
  count: number;
  participationCount: number;
  pageNumber: number;
  pageSize: number;
  totalPages: number;
  hasPreviousPage: boolean;
  hasNextPage: boolean;
  participations: AuctionParticipationAudit[];
  sessions: AuctionAudienceSession[];
}

The participation audit's lastSeenAt is the durable latest departure timestamp and may remain populated after a participant reconnects; use realtime presence for current status.

count and pagination metadata are based on sessions. participationCount is the independently filtered durable-participation count. The same page parameters are applied to both arrays. Company responses always return ip: null and userAgent: null; only super-admin audit access may expose stored values.

8. WebSocket request and response envelopes

Connect to /ws using the existing authenticated handshake. Client requests use:

interface ClientSocketMessage<T> {
  id?: string;
  type: string;
  version?: string;
  source?: string;
  timestamp?: string;
  data?: T;
  meta?: { traceId?: string };
}

The id is mandatory for participation joins and bid placement. Generate one stable unique ID per logical command and reuse it for retries.

interface SocketAck<T> {
  id: string;
  type: 'ack';
  version: string;
  source: string;
  timestamp: string;
  sequence: number;
  data: { eventType: string } & T;
  meta?: { traceId?: string };
}

Errors are correlated by request id:

{
  "id": "join-01",
  "type": "error",
  "version": "1.0",
  "source": "dichit-backend",
  "timestamp": "2026-09-06T09:49:00.000Z",
  "sequence": 18,
  "data": {
    "code": "FORBIDDEN",
    "message": "Subscriber join window has not opened for this auction."
  }
}

Codes are BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, RATE_LIMITED, and INTERNAL.

9. Subscribe as a viewer or returning participant

{
  "id": "subscribe-01",
  "type": "auction.subscribe",
  "data": {
    "auctionId": "cm123auction",
    "enrolledSubscriberId": "cm123enrollment",
    "lastSequence": 42
  }
}

Validation:

  • auctionId: required non-empty string.
  • enrolledSubscriberId: optional non-empty string; supply it when multiple enrollments can match.
  • lastSequence: optional non-negative integer.
  • Permission: auction:subscribe.
  • Subscriber viewing requires an eligible active enrollment and auction status SCHEDULED, READY, LIVE, or PAUSED.
  • Payment and an open join window are not required.
  • The server derives VIEWER or PARTICIPANT; clients cannot send an access mode.

Ack data:

{
  "eventType": "auction.subscribe",
  "auctionId": "cm123auction",
  "cycleId": "cm123cycle",
  "room": "auction:cm123auction"
}

Staff acknowledgements also include staffRoom. After the ack, the server sends auction.connected, auction.state.snapshot, and auction.audience.snapshot, in that order.

auction.connected.data.payload is:

{
  "connectionId": "connection-id",
  "heartbeatIntervalMs": 15000
}

Subscriber state snapshots contain:

interface SubscriberStateSnapshot extends AuctionAudienceCounts {
  auction: object;
  currentUser: LiveAuctionCurrentUser;
  recentBids: object[];
  lastSequence: number;
  replay?: { events: DurableAuctionEvent[]; lastSequence: number };
}

They never contain onlineSubscribers. A staff snapshot may additionally contain allSubscribers, onlineSubscribers, actionLogs, and announcements. Participant entries in those staff collections include joinedAt and normalized lastSeenAt; onlineSubscribers also includes presenceStatus: 'PRESENT'. Present entries always have lastSeenAt: null.

interface StaffOnlineAudienceMember {
  subscriberId: string;
  enrolledSubscriberId: string;
  name: string | null;
  avatar: string | null;
  accessMode?: AccessMode;
  joinedAt: string | null;
  presenceStatus: 'PRESENT';
  lastSeenAt: null;
  connectionCount: number;
  source: 'ONLINE' | 'FLOOR';
}

interface StaffStateSnapshot extends AuctionAudienceCounts {
  auction: object;
  allSubscribers: StaffAuctionParticipant[];
  onlineSubscribers: StaffOnlineAudienceMember[];
  recentBids: object[];
  actionLogs: object[];
  announcements: object[];
  lastSequence: number;
  replay?: { events: DurableAuctionEvent[]; lastSequence: number };
}

allSubscribers is the full staff roster and can contain viewers, joined participants, and eligible subscribers who have never connected. onlineSubscribers is the current realtime projection. Rebuild both collections from each new staff state snapshot rather than merging an old snapshot.

10. Explicit participation join

{
  "id": "join-01",
  "type": "auction.participation.join",
  "data": {
    "auctionId": "cm123auction",
    "enrolledSubscriberId": "cm123enrollment",
    "confirmFloorToOnline": false
  }
}

Validation:

  • Message id is required and serves as the idempotency key.
  • Payload is strict; extra fields are rejected.
  • auctionId is a required non-empty string.
  • enrolledSubscriberId is optional and non-empty when supplied.
  • confirmFloorToOnline defaults to false.
  • Permission: auction:subscribe.
  • Rate limit: 10 commands per configured WebSocket rate-limit window.
  • Identity and enrollment ownership are revalidated server-side.
  • Cycle and enrollment must be active and eligible.
  • Subscriber must not be a prior program winner or live-disqualified.
  • A paid cycle invoice is required for first admission.
  • Database time must be inside the current join window.
  • Access mode, participant ID, invoice ID, timestamp, and window version are server-derived and rejected as extra fields.

Ack:

{
  "id": "join-01",
  "type": "ack",
  "data": {
    "eventType": "auction.participation.join",
    "status": "accepted",
    "commandId": "join-01",
    "participantId": "participant-id",
    "auctionId": "cm123auction",
    "cycleId": "cm123cycle",
    "joinedAt": "2026-09-06T09:50:03.000Z",
    "lastSeenAt": null,
    "joinWindowVersion": 2,
    "source": "ONLINE"
  }
}

status is accepted for the creator and replayed for an idempotent retry or already admitted enrollment. Treat both as success. Do not generate a new ID after a timeout; retry the original command ID.

If the durable source is FLOOR, an unconfirmed online join is a no-op and the ack has status: "confirmation_required", participantId, currentSource: "FLOOR", and requestedSource: "ONLINE". The socket remains a viewer. Ask the subscriber, then resend with confirmFloorToOnline: true using the intended command ID. A confirmed switch rechecks payment, winner, disqualification, and the current online join window.

11. Bid placement after admission

{
  "id": "bid-01",
  "type": "auction.bid.place",
  "data": {
    "auctionId": "cm123auction",
    "enrolledSubscriberId": "cm123enrollment",
    "amountMinor": "2500000"
  }
}

An online subscriber must now have durable participation. Connection, payment, or an open join window alone is insufficient.

Validation:

  • Message id is required and is the bid idempotency key.
  • Payload is strict.
  • amountMinor is a positive base-10 integer string, at most 19 digits, and no greater than 9223372036854775807.
  • auctionId is required and non-empty; enrolledSubscriberId is optional and non-empty when supplied.
  • Permission: auction:bid:place; rate limit: 100 commands per configured window.
  • Auction must be LIVE; paused auctions reject bids.
  • Admission, amount, winner, disqualification, and concurrency rules are rechecked transactionally.
interface BidAckData {
  eventType: 'auction.bid.place';
  status: 'accepted' | 'replayed';
  commandId: string;
  auctionId: string;
  cycleId: string;
  bidId: string;
  amountMinor: string;
  stateVersion: number;
}

Use currentUser.canBid and bidBlockedReason for UI state; the server remains authoritative.

12. Unsubscribe

{
  "id": "unsubscribe-01",
  "type": "auction.unsubscribe",
  "data": { "auctionId": "cm123auction" }
}

auctionId is required and non-empty. Ack data is:

{
  "eventType": "auction.unsubscribe",
  "auctionId": "cm123auction",
  "unsubscribed": true
}

The connection audit session closes with UNSUBSCRIBE. Durable participation is unchanged. unsubscribed is false when no active subscription existed.

13. Staff floor presence

{
  "id": "floor-01",
  "type": "auction.presence.floor.update",
  "data": {
    "auctionId": "cm123auction",
    "enrolledSubscriberId": "cm123enrollment",
    "present": true
  }
}

Validation:

  • Staff only; permission auction:staff.
  • All payload fields are required; IDs are non-empty and present is boolean.
  • Enrollment must be eligible.
  • present: true requires payment and creates durable FLOOR admission when needed.
  • present: false removes the current floor overlay while retaining admission.

This is an asynchronous staff command. Use audience and staff events to reconcile the resulting state.

14. Server event envelope

interface AuctionServerEvent<T> {
  id?: string;
  type: string;
  version: string;
  source: string;
  timestamp: string;
  sequence: number;
  data: {
    auctionId: string;
    cycleId: string;
    stateVersion?: number;
    serverSequence?: number;
    requestId?: string;
    payload: T;
  };
}

sequence orders envelopes on one connection. data.serverSequence is the durable per-auction cursor used for replay. Do not interchange them.

15. Audience snapshots and changes

auction.audience.snapshot is sent after subscription:

{
  "type": "auction.audience.snapshot",
  "data": {
    "auctionId": "cm123auction",
    "cycleId": "cm123cycle",
    "payload": {
      "viewerCount": 4,
      "joinedParticipantCount": 3,
      "onlineParticipantCount": 2,
      "bidderCount": 1
    }
  }
}

The public and staff auction.audience.changed payloads contain the same four counts plus the affected participant projection shown below. Replace all counters on either event. If the current subscriber is joined, debounce a refetch of the currently displayed REST participant page so presenceStatus and lastSeenAt remain current. Do not refetch participant pages for viewers, because their REST audience.participants value remains null. The changed payload has:

interface StaffAudienceChangedPayload extends AuctionAudienceCounts {
  participant: {
    subscriberId: string;
    enrolledSubscriberId: string;
    name: string | null;
    avatar: string | null;
    accessMode?: AccessMode;
    lastSeenAt: string;
    connectionCount: number;
    source: 'ONLINE' | 'FLOOR';
  };
}

The changed-event participant is an ephemeral presence projection. Its existing lastSeenAt value is the realtime observation timestamp, not the nullable durable departure field used by REST and staff state snapshots. It does not include durable joinedAt: providing that value would require a participant lookup for every presence transition. Use auction.state.snapshot, the staff auction overview, or the participation audit when durable timestamps are needed.

Audience events are ephemeral and may be missed during disconnection. Replace them from the next snapshot after reconnecting.

16. Durable join-window events

Opened event:

{
  "id": "event-id",
  "type": "auction.join_window.opened",
  "data": {
    "auctionId": "cm123auction",
    "cycleId": "cm123cycle",
    "serverSequence": 43,
    "payload": {
      "stateVersion": 7,
      "joinWindow": {
        "status": "OPEN",
        "version": 2,
        "joiningRule": "GRACE_PERIOD",
        "opensAt": "2026-09-06T09:50:00.000Z",
        "openedAt": "2026-09-06T09:50:00.080Z",
        "closesAt": "2026-09-06T10:02:00.000Z",
        "closedAt": null
      }
    }
  }
}

Closed event:

{
  "id": "event-id",
  "type": "auction.join_window.closed",
  "data": {
    "auctionId": "cm123auction",
    "cycleId": "cm123cycle",
    "serverSequence": 44,
    "payload": {
      "stateVersion": 8,
      "joinWindow": {
        "status": "CLOSED",
        "version": 2,
        "joiningRule": "GRACE_PERIOD",
        "opensAt": "2026-09-06T09:50:00.000Z",
        "openedAt": "2026-09-06T09:50:00.080Z",
        "closesAt": "2026-09-06T10:02:00.000Z",
        "closedAt": "2026-09-06T10:02:00.015Z"
      },
      "reason": "GRACE_EXPIRED"
    }
  }
}

Close reasons are STARTED, GRACE_EXPIRED, AUCTION_ENDED, CANCELLED, and RESCHEDULED.

  • Opening is exactly auctionStartAt - 600 seconds.
  • BEFORE_START closes when the auction actually starts.
  • GRACE_PERIOD closes at startedAt + subscriberJoinGraceSeconds; zero closes at start.
  • ANYTIME closes when the auction ends or is cancelled.
  • Manual early start opens immediately before starting.
  • Pause, resume, and extension do not revoke admission.
  • Rescheduling closes the old open version and creates a new version.

These events are delivered at least once. Deduplicate by id, apply in serverSequence order, and ignore a window event older than the currently rendered version.

17. Staff participation event

auction.participation.joined is durable and staff-only:

{
  "id": "event-id",
  "type": "auction.participation.joined",
  "data": {
    "auctionId": "cm123auction",
    "cycleId": "cm123cycle",
    "serverSequence": 45,
    "payload": {
      "participantId": "participant-id",
      "subscriberId": "subscriber-id",
      "enrolledSubscriberId": "cm123enrollment",
      "source": "ONLINE",
      "joinedAt": "2026-09-06T09:51:00.000Z",
      "lastSeenAt": null,
      "joinWindowVersion": 2
    }
  }
}

source is ONLINE or FLOOR. Subscriber clients learn their own admission from the join ack and snapshots, not this event.

Source switches emit the staff-only durable event auction.participation.source_changed with participantId, subscriber and enrollment IDs, fromSource, toSource, and changedAt. Staff marking an online participant present on the floor changes the durable source to FLOOR and downgrades open sockets to viewers. Removing floor presence does not restore online participation; the subscriber must complete the confirmed join flow.

18. Replay and reconnect

Store the highest applied durable data.serverSequence per auction.

  1. Reconnect with a fresh authenticated socket.
  2. Send auction.subscribe with lastSequence.
  3. Wait for the subscribe ack.
  4. Replace local state with auction.state.snapshot.
  5. Apply visible snapshot.payload.replay.events in ascending serverSequence. The sequence can be sparse because replay omits events the caller is not authorized to see.
  6. Replace all counters from auction.audience.snapshot.
  7. Apply subsequent durable events only when their sequence is newer, and advance the cursor to replay.lastSequence even when filtered events created holes in the visible list.
  8. Deduplicate by durable event id as an additional guard.

Delivery is at least once. Resubscribe with the last applied high-water cursor after transport loss or inconsistent state; a numeric gap by itself is not an error. Recover ephemeral audience state from snapshots.

const showAuctionDetails = currentUser.canViewDetails;
const connectRealtime = currentUser.canSubscribeRealtime;
const showJoinButton = currentUser.canJoin;
const showJoinedBadge = currentUser.hasJoined;
const enablePrebidForm = currentUser.canPrebid;
const enableBidForm = currentUser.canBid;

Use joinBlockedReason and bidBlockedReason for user-facing disabled states. Avoid client-clock authorization decisions.

On join click:

  1. Generate one command ID and disable duplicate clicks for that ID.
  2. Send auction.participation.join.
  3. Keep the command pending across reconnects.
  4. Retry with the same ID after uncertain delivery.
  5. Treat accepted and replayed as success.
  6. Set local admission from the ack.
  7. Reconcile shared state from events and snapshots.

For the participant roster:

function onAudienceChanged(): void {
  replaceAudienceCountsFromEvent();

  if (!currentUser.hasJoined || !isParticipantPageVisible) return;
  debounceRefetchCurrentParticipantPage();
}
  • Fetch participant pagination from the REST detail endpoint; do not attempt to construct the roster from public WebSocket events.
  • Preserve the active participantPageNumber and participantPageSize when refetching.
  • Replace the page response atomically so counts, status, and timestamps are rendered from one response.
  • Render lastSeenAt only when presenceStatus === 'LEFT' and the value is not null. For null, use neutral copy such as “Not currently present”; do not invent a timestamp.
  • Render joinSource independently from presence, for example “Joined online · Left 2 minutes ago” or “Joined on floor · Present”.
  • Use the server timestamp for formatting but the client clock only for display text such as relative time. Never use relative-time calculations for access decisions.

20. Frontend acceptance checklist

  • An unpaid eligible subscriber can fetch, subscribe, and watch as VIEWER.
  • An eligible subscriber can fetch PENDING auction details without opening a live-room socket.
  • A paid, non-winning subscriber can prebid during an open PENDING, SCHEDULED, or READY phase without joining the live auction.
  • An unpaid viewer cannot join or bid.
  • A paid viewer can join only when currentUser.canJoin is true.
  • Joining does not submit a bid.
  • A participant can reconnect after the join window closes.
  • A participant can bid until auction end while status is LIVE.
  • A participant cannot bid while PAUSED.
  • Multiple tabs create separate sessions without inflating counts.
  • Public auction.audience.changed decodes the affected subscriber projection; subscriber audience snapshots remain counts-only.
  • Subscriber REST participant identities render only after persisted window opening and only for a requester who has joined.
  • Non-joined subscribers render audience counts with no participant roster.
  • A cancelled auction whose window never opened renders audience: null.
  • Joined subscribers retain roster visibility after window closure and auction completion.
  • ONLINE/FLOOR join source renders independently of PRESENT/LEFT status.
  • Present participants never render a stale lastSeenAt value.
  • Left participants render the newest departure timestamp when available.
  • Joined participants never observed present tolerate lastSeenAt: null.
  • Join-window events update the UI without refresh.
  • Reschedule events replace the displayed window only when their version is newer.
  • Durable events are deduplicated and ordered by serverSequence.
  • Staff filters, floor presence, sessions, and participation audits render independently.
  • Company audit screens never expect IP or user-agent values.