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 isVIEWER.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¶
Validation:
auctionIdmust be a valid CUID belonging to an auction visible to the authenticated subscriber.participantPageNumberdefaults to1and must be at least1.participantPageSizedefaults to10and must be between1and100.- 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:
joinSourceis immutable history describing how admission was created.presenceStatusis current realtime state and may change repeatedly.joinedAtnever changes after admission.lastSeenAtrepresents 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 optionalsignatureAssetIdmust be CUIDs.amountmust be a valid JSON number and satisfy the auction bid mode, minimum, and total-amount rules.signatureAssetIdis required by the business flow whenprebidDocumentRequiredis true.- The authenticated subscriber must own the enrollment for this auction cycle.
- The server rechecks every
canPrebidcondition inside a locked transaction using database time. - Only one active, non-deleted prebid is permitted per auction and enrollment.
canPrebidis advisory UI state. Handle a rejected POST because eligibility can change after the snapshot.
4. Subscriber live-room bootstrap¶
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
reasonmust be supplied. auctionStartAtmust benullor a valid date-time today or in the future.auctionDurationSecondsmust benullor an integer of at least1.- Extension, call, and grace durations must be
nullor 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.
prebidMutabilityPolicyis 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, orPAUSED. - Payment and an open join window are not required.
- The server derives
VIEWERorPARTICIPANT; 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:
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
idis required and serves as the idempotency key. - Payload is strict; extra fields are rejected.
auctionIdis a required non-empty string.enrolledSubscriberIdis optional and non-empty when supplied.confirmFloorToOnlinedefaults tofalse.- 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
idis required and is the bid idempotency key. - Payload is strict.
amountMinoris a positive base-10 integer string, at most 19 digits, and no greater than9223372036854775807.auctionIdis required and non-empty;enrolledSubscriberIdis 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¶
auctionId is required and non-empty. Ack data is:
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
presentis boolean. - Enrollment must be eligible.
present: truerequires payment and creates durableFLOORadmission when needed.present: falseremoves 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_STARTcloses when the auction actually starts.GRACE_PERIODcloses atstartedAt + subscriberJoinGraceSeconds; zero closes at start.ANYTIMEcloses 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.
- Reconnect with a fresh authenticated socket.
- Send
auction.subscribewithlastSequence. - Wait for the subscribe ack.
- Replace local state with
auction.state.snapshot. - Apply visible
snapshot.payload.replay.eventsin ascendingserverSequence. The sequence can be sparse because replay omits events the caller is not authorized to see. - Replace all counters from
auction.audience.snapshot. - Apply subsequent durable events only when their sequence is newer, and advance the cursor to
replay.lastSequenceeven when filtered events created holes in the visible list. - Deduplicate by durable event
idas 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.
19. Recommended UI flow¶
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:
- Generate one command ID and disable duplicate clicks for that ID.
- Send
auction.participation.join. - Keep the command pending across reconnects.
- Retry with the same ID after uncertain delivery.
- Treat
acceptedandreplayedas success. - Set local admission from the ack.
- 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
participantPageNumberandparticipantPageSizewhen refetching. - Replace the page response atomically so counts, status, and timestamps are rendered from one response.
- Render
lastSeenAtonly whenpresenceStatus === 'LEFT'and the value is notnull. Fornull, use neutral copy such as “Not currently present”; do not invent a timestamp. - Render
joinSourceindependently 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
PENDINGauction details without opening a live-room socket. - A paid, non-winning subscriber can prebid during an open
PENDING,SCHEDULED, orREADYphase without joining the live auction. - An unpaid viewer cannot join or bid.
- A paid viewer can join only when
currentUser.canJoinis 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.changeddecodes 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/FLOORjoin source renders independently ofPRESENT/LEFTstatus.- Present participants never render a stale
lastSeenAtvalue. - 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.