Auction Bid Activity Feed — Realtime Frontend Integration¶
Target audience: frontend engineers building the live bidding screen (subscriber view) and the auction control room (staff view).
This guide explains how to integrate the auction bid activity feed: the ordered, realtime stream of bids and auction-state changes that powers the live bidding screen. It covers the WebSocket contract end to end — connection, subscribe snapshot, realtime events, submitting bids, ordering, and reconnect recovery.
The feed is server-sequenced: every feed event carries a monotonically increasing serverSequence per auction cycle. Clients use that number to order events, deduplicate messages, and resume from the last seen sequence after a reconnect.
1. Scope¶
This document covers the auction bid activity feed over WebSocket:
- Subscribing to an auction room
- The initial snapshot (
auction.state.snapshot) withrecentBids+lastSequence - Realtime feed events:
auction.bid.created,auction.bid.updated,auction.state.changed - Related room events:
auction.bid.deleted,auction.bidder.disqualified, and aggregate audience events - Placing a bid (
auction.bid.place) and its acknowledgements - Reconnect / replay semantics via
lastSequence
Out of scope: REST auction detail (GET /auctions/:auctionId), prebid flow, settings & policy resolution, winner declaration REST APIs. Those are covered in auction-frontend-integration.md and auction-settings-integration.md.
2. Connection & authentication¶
Connect to the WebSocket endpoint with the standard connection/auth handshake used by the whole app:
- All auction messages carry
source: "auction-service"in the envelope. - Roles:
SUBSCRIBER(bidders),COMPANY(company staff),SUPERADMIN(platform admin). - Permission strings checked by the server:
auction:subscribe,auction:bid:place(andauction:stafffor staff commands). These are granted through the normal auth flow. - A subscriber with an eligible enrollment may enter as a viewer without payment. Payment is required for durable participation and bidding. Staff can enter auctions of their own company (admins: any).
A single connection may subscribe to one auction room at a time per cycle; subscribing to a cycle you are already subscribed to replaces the previous subscription.
3. Message envelope¶
Client → server¶
{
"id": "client-generated-request-id", // optional; used for acks and command correlation
"type": "auction.subscribe", // the command/query type
"data": {/* command payload */}
}
Server → client¶
Events sent as broadcasts during a subscription:
{
"id": "event-id",
"type": "auction.bid.created", // event type (see §6)
"version": "1.0",
"source": "auction-service",
"timestamp": "2026-08-18T10:30:00.000Z",
"data": {
"auctionId": "auction-1",
"cycleId": "cycle-1",
"stateVersion": 12, // optional, present on live-state events
"serverSequence": 7, // optional, present on feed events
"payload": {/* per-type shape, see §6 */}
}
}
Acknowledegement / error envelopes use the generic app envelopes. Command envelopes carry correlationId (the client-generated request id) and causationId (the command id) so you can correlate a later failure with the original request:
command.acknowledged— async command accepted (data.command,data.status: "accepted").command.failed— async command ran but failed (data.command,data.errorCode,data.message).command.rejected— validation/authorization failure (data.command,data.errorCode,data.message).error— sync query failed (data.code,data.message).ack— sync query succeeded (data.eventType+ the handler result).
4. Subscribe lifecycle¶
4.1 auction.subscribe (query)¶
{
"id": "req-123",
"type": "auction.subscribe",
"data": {
"auctionId": "auction-1"
// "enrolledSubscriberId": "...", // only when the authenticated user manages multiple
// "lastSequence": 42 // optional; pass to resume after a reconnect
}
}
The server:
- Authorizes an eligible active enrollment. Watching does not require payment.
- Joins the room, subscribes to realtime events, and opens a per-connection audience session.
- Sends
ack(data.eventType = "auction.subscribe"). - Sends
auction.connectedwithdata.payload = { connectionId, heartbeatIntervalMs }. - Sends
auction.state.snapshot— the full snapshot (§4.3). - Sends
auction.audience.snapshotwith aggregate audience counts. Subscriber payloads contain no identities.
auction.subscribe is a sync query: the ack confirms the room membership only; the data you render comes from the auction.state.snapshot event and the realtime events that follow.
4.2 auction.unsubscribe (query)¶
Leaves the room, unregisters presence, stops realtime delivery. Reply ack with data.unsubscribed: boolean. On socket close the server performs the same cleanup.
4.3 auction.state.snapshot payload¶
Subscriber snapshot:
{
"auction": {/* full subscriber auction detail object, incl. currentPhase */},
"currentUser": {/* subscriber's own auction context */},
"viewerCount": 4,
"joinedParticipantCount": 3,
"onlineParticipantCount": 2,
"bidderCount": 1,
"recentBids": [/* AuctionBidActivityItem[], newest-first, up to 50, see §5 */],
"lastSequence": 42
}
Staff snapshot adds management surfaces:
{
"auction": {
/* full admin auction detail object, incl. bids/prebids/participants pages */
},
"allSubscribers": [/* participants with latestBidAmount, isOnline, lastSeenAt */],
"onlineSubscribers": [/* same shape as above */],
"recentBids": [/* AuctionBidActivityItem[] */],
"actionLogs": [/* staff action log entries */],
"announcements": [/* latest announcements */],
"lastSequence": 42
}
recentBids is the tail of the feed (most recent 50 items, newest first). It is the same item shape used by realtime auction.bid.created / auction.bid.updated events. lastSequence is the highest serverSequence observed so far for this cycle — store it for reconnect recovery.
4.4 Reconnecting with lastSequence¶
On reconnect (socket drop, network change, app resume):
- Reconnect to
/ws. - Send
auction.subscribewith theauctionId(fetch via REST; row guaranteed beforeREADY/LIVE) and the lastlastSequenceyou applied.
Because you provide lastSequence, the server appends a replay field to the snapshot instead of relying on recentBids:
{
"auction": {/* full auction detail */},
"currentUser": {/* ... */},
"viewerCount": 4,
"joinedParticipantCount": 3,
"onlineParticipantCount": 2,
"bidderCount": 1,
"recentBids": [/* tail (may be older than lastSequence) */],
"lastSequence": 42,
"replay": {
"auction": {/* auction record used to build the feed (internal) */},
"events": [
{
"type": "auction.bid.created", // or auction.bid.updated / auction.state.changed
"id": "event-id",
"serverSequence": 43,
"payload": {
/* AuctionBidActivityItem | AuctionLifecyclePayload | other visible durable payload */
}
}
],
"lastSequence": 47
}
}
Client recovery rule: discard recentBids and rebuild the visible feed from replay.events applied on top of your last applied sequence; then set lastSequence = replay.lastSequence. If no lastSequence is sent on subscribe, there is no replay and recentBids is the current tail.
5. Feed item — AuctionBidActivityItem¶
Every bid appears in recentBids and in auction.bid.created / auction.bid.updated events as this item:
{
"id": "bid-event-1",
"auctionId": "auction-1",
"cycleId": "cycle-1",
"subscriberProgramId": "es-1",
"subscriberId": "u-1",
"bidderName": "Aarav",
"bidderAvatarUrl": "https://...",
"bidderCode": "****5678",
"bidAmount": "1500.00",
"bidRank": 1,
"isWinningBid": true,
"status": "ACCEPTED",
"createdAt": "2026-08-18T10:30:00.000Z",
"serverSequence": 43,
"origin": "ONLINE"
}
| Field | Type | Notes |
|---|---|---|
id | string | Bid id. Stable across the event log; use for deduplication. |
auctionId, cycleId | string | Scope of the feed. |
subscriberProgramId | string | Enrolled subscriber id (per program+cycle). |
subscriberId | string | Authenticated user id of the bidder. |
bidderName | string | Display name; falls back to "Subscriber". |
bidderAvatarUrl | string | null | Avatar URL if set. |
bidderCode | string | null | Masked mobile number (last 4 digits visible). Never render raw. |
bidAmount | string | Decimal string, e.g. "1500.00". Parse with a decimal-aware parser, never float-multiply for display currency. |
bidRank | number | null | 1 when currently leading, null otherwise. |
isWinningBid | boolean | true for the currently leading bid. |
status | ACCEPTED | OUTBID | REJECTED | CANCELLED | Live status of this bid in the feed (see below). |
createdAt | string (ISO) | When the bid was placed. |
serverSequence | number | Order key in the feed. Monotonic per cycle. |
origin | ONLINE | FLOOR | AUTO | Origin: subscriber online, staff floor/offline bid, or system/auto bid. |
Bid status semantics¶
| Status | Meaning | Rendered as |
|---|---|---|
ACCEPTED | The bid is the current leading bid. | Live leading bid, rank 1. |
OUTBID | The bid used to lead but a higher bid replaced it. | Outbid entry, no longer leading. |
REJECTED | Reserved for rejected/declined bids (no current producer). | Not shown / error state. |
CANCELLED | Reserved for canceled/disqualified bids (no current producer). | Not shown / strike-through. |
Today only ACCEPTED and OUTBID are emitted. Handle REJECTED/CANCELLED defensively (treat as terminal, non-winning).
Subscriber highlight rule¶
A subscriber can identify their own bids by comparing subscriberId (and subscriberProgramId) with the current user. Only the leading bid has bidRank: 1 and isWinningBid: true at any moment — when a subscriber outbids themselves, the previous ACCEPTED item flips to OUTBID.
6. Realtime feed events¶
Feed events arrive as broadcasts. Durable auction events use a uniform outer envelope and expose their domain payload directly at data.payload:
"data": {
"auctionId": "auction-1",
"cycleId": "cycle-1",
"serverSequence": 43,
"payload": { /* AuctionBidActivityItem | AuctionLifecyclePayload */ }
}
Use data.serverSequence for durable ordering. Bid activity items also carry their own matching payload.serverSequence.
auction.bid.created¶
A new bid was accepted and is now leading.
data.payload= anAuctionBidActivityItemwithstatus: "ACCEPTED",isWinningBid: true,bidRank: 1.
Client action: prepend to the feed, mark as leading. If a bid from the same subscriberId was previously leading, the feed now shows that user twice (old entry becomes OUTBID via the accompanying auction.bid.updated).
auction.bid.updated¶
The previously leading bid was outbid.
data.payload= the previous leadingAuctionBidActivityItemwithstatus: "OUTBID",isWinningBid: false,bidRank: null.- Arrives immediately before the
auction.bid.createdfor the higher bid (both share the samecreatedAtbatch; theirserverSequencevalues differ).
Client action: locate the item with matching id (or subscriberProgramId + bidAmount) and demote it to outbid.
auction.state.changed — lifecycle transitions¶
All updated lifecycle command paths persist the same canonical payload:
"data.payload": {
"transition": "started",
"state": { /* complete post-transition AuctionLifecycleState */ }
}
| transition | Trigger |
|---|---|
prebid_opened | Prebid phase opened. |
prebid_closed | Prebid phase closed. |
prebids_revealed | Prebid amounts revealed. |
approved | Approval-required auction became READY. |
started | Auction went live. |
paused | Auction paused (staff). |
resumed | Auction resumed (staff). |
extended | Auction auto-extended by a bid near the end. |
closed | Auction ended. |
call_started | Post-end winner call started. |
winner_selected | Winner declared; payload includes status. |
winner_disqualified | Declared winner disqualified. |
schedule_changed | Auction timing/settings changed. |
cancelled | Auction cancelled. |
Client action: replace the lifecycle slice from data.payload.state. Do not re-derive deadlines or read transition-specific timing fields at the payload root.
Related room events¶
| Event | Payload (data.payload) | Notes |
|---|---|---|
auction.bid.deleted | { bidId, leadingBidAmount, metadata: { enrolledSubscriberId } } | Staff deleted a bid. Remove the matching feed item. |
auction.bidder.disqualified | { bidId: null, leadingBidAmount, metadata: { disqualificationId, enrolledSubscriberId, phase } } | Targeted — only delivered to the disqualified subscriber. Show the ban state. |
auction.announcement.created / updated / deleted | announcement object | Staff announcements panel. |
auction.audience.changed | Aggregate counts plus affected participant in public and staff copies. | Replace counters; treat participant data as ephemeral. |
auction.settings.updated | settings object | Effective settings changed; re-resolve local policy state. |
7. Auction status reference¶
auction.state.snapshot.auction.auctionStatus and the persisted auction.state.changed payload use the real, unmapped AuctionStatus:
| Status | Meaning |
|---|---|
PENDING | Scheduled but not yet armed. |
SCHEDULED | Armed and waiting for auctionStartAt. |
READY | Pre-live ready (only when approvalRequired is enabled). |
LIVE | Bidding open. |
PAUSED | Temporarily paused by staff. |
ENDED | Bidding closed, winner resolution in progress. |
COMPLETE | Cycle finalized. |
CANCELLED | Auction canceled. |
No folding is applied anywhere in the feed: PENDING stays PENDING, PAUSED stays PAUSED, etc. Only bid on LIVE.
8. Ordering, deduplication & gaps¶
- Every feed event has
serverSequence(top-leveldata.serverSequence) that is strictly increasing within a cycle. Order the visible feed by it (descending in the UI). bidAmountcomparisons, not float math, should drive "highest bid" UI logic when the server event log is replayed.- Deduplicate durable delivery by the outer event
id.data.payload.idon a bid event is the bid id and is used to update the bid row. - The visible sequence can be sparse because staff-only and target-user events are filtered out. Do not interpret
incoming > last + 1as loss by itself. After actual transport loss or inconsistent state, resubscribe with the last applied/reported high-water cursor and advance toreplay.lastSequence. recentBidsin a reconnect snapshot is a tail, not a diff. Rebuild fromreplay.eventswhen present.
9. Placing a bid¶
9.1 auction.bid.place (synchronous command)¶
{
"id": "bid-request-001", // becomes the idempotency command id
"type": "auction.bid.place",
"data": {
"auctionId": "auction-1",
"amountMinor": "150000" // decimal string in minor units (paise/cents), no leading zeros
// "enrolledSubscriberId": "es-1" // only when the authenticated user manages multiple
}
}
Notes:
amountMinoris the amount in minor currency units as a string (e.g."150000"= 1500.00). Regex-validated server-side as/^[1-9]\d*$/.- Rate limited (
max: 100per window) forauction.bid.place. - PostgreSQL serializes concurrent requests on the auction row. The server returns one terminal
ackafter commit (acceptedorreplayed), or a correlatederrorfor a rejection. Realtime feed events still update all room clients.
9.2 Acknowledgements & failures¶
| Envelope | Meaning | Client action |
|---|---|---|
ack (data.status = "accepted") | Bid committed. | Clear pending and reconcile the feed. |
ack (data.status = "replayed") | The same command was already committed. | Clear pending and reconcile the feed. |
error | Validation, authorization, or business rejection. | Show safe message; reset pending state. |
auction.bid.created | Your bid was accepted and is leading. | Clear pending; update feed. |
auction.bid.updated | Your bid was outbid. | Mark outbid. |
Idempotency: reuse the same id for a retry of the same bid (same amount) — the server returns the persisted terminal status instead of executing the bid again. Changing the amount under the same id is rejected. id is required for auction.bid.place.
On a connection failure with an uncertain outcome, retry the identical payload with the same id. A database or transport error is not an acceptance signal.
Error-code reference: map the correlated error code to localized user copy:
errorCode | Meaning |
|---|---|
AUCTION_NOT_LIVE | Auction is not live (not started, window closed, or cycle inactive). |
AUCTION_ENDED | Auction already ended. |
INVALID_AMOUNT | Amount failed validation (non-positive or malformed). |
BID_NOT_IMPROVING | Bid is below the minimum, above the maximum, or not an improvement over the current lead. |
DUPLICATE_COMMAND | Same command id reused with a different amount. |
SUBSCRIBER_NOT_ELIGIBLE | Payment, eligibility, or winning constraints are not met. |
Place staff offline bids (origin: floor) through the same auction.bid.place command with an enrolledSubscriberId target; the server rejects staff floor bids when the target subscriber is online.
9.3 Auto-extension¶
A bid placed in the final stretch may trigger auction.state.changed with data.payload.transition === "extended". Restart the countdown from data.payload.state.auctionEndAt. Do not treat this as a bid failure.
10. Audience & floor¶
auction.audience.snapshotarrives once per subscribe withviewerCount,joinedParticipantCount,onlineParticipantCount, andbidderCount.auction.audience.changedcarries the same aggregate fields plus the affected participant in both public and staff-scoped copies.- Each socket is a separate durable audience session. Heartbeats keep the session active; expiry closes it with
HEARTBEAT_EXPIRED. - Staff may toggle floor presence with
auction.presence.floor.update. Marking a subscriber present verifies payment and the current join window, creates immutableFLOORadmission, and publishes updated audience counts. Removing floor presence does not revoke admission.
11. Reference TypeScript types¶
type AuctionStatus =
| 'PENDING'
| 'SCHEDULED'
| 'READY'
| 'LIVE'
| 'PAUSED'
| 'ENDED'
| 'COMPLETE'
| 'CANCELLED';
type AuctionBidActivityStatus = 'ACCEPTED' | 'REJECTED' | 'OUTBID' | 'CANCELLED';
type AuctionBidOrigin = 'ONLINE' | 'FLOOR' | 'AUTO';
interface AuctionBidActivityItem {
readonly id: string;
readonly auctionId: string;
readonly cycleId: string;
readonly subscriberProgramId: string;
readonly subscriberId: string;
readonly bidderName: string;
readonly bidderAvatarUrl?: string | null;
readonly bidderCode?: string | null;
readonly bidAmount: string; // decimal string, e.g. "1500.00"
readonly bidRank?: number | null; // 1 = currently leading
readonly isWinningBid: boolean;
readonly status: AuctionBidActivityStatus;
readonly createdAt: string; // ISO
readonly serverSequence: number;
readonly origin: AuctionBidOrigin;
}
interface AuctionLifecyclePayload {
readonly transition: string;
readonly state: {
readonly status: AuctionStatus;
readonly stateVersion: number;
};
}
type AuctionBidActivityActionEvent =
| {
readonly type: 'auction.bid.created' | 'auction.bid.updated';
readonly id: string;
readonly serverSequence: number;
readonly payload: AuctionBidActivityItem;
}
| {
readonly type: 'auction.state.changed';
readonly id: string;
readonly serverSequence: number;
readonly payload: AuctionLifecyclePayload;
};
12. Recommended client state machine¶
IDLE ── auction.subscribe ──> CONNECTING ── auction.state.snapshot ──> SYNCED
SYNCED ── feed event ──> apply + render ──> SYNCED
SYNCED ── socket closed ──> RECONNECTING (retry with last applied lastSequence)
RECONNECTING ── auction.state.snapshot (+replay) ──> reconcile ──> SYNCED
SYNCED ── terminal state (ENDED / COMPLETE / CANCELLED) ──> TERMINAL
- Keep
lastSequence(the highest applied sequence) and the full ordered feed in memory for the duration of the room session. - Never clear the feed on reconnect; reconcile on top of it.
- Disable the bid submit control unless the latest
auctionStatus === 'LIVE'(and your own participation state permits bidding). - Show an "outbid" state immediately on
auction.bid.updated; a higher bid from the same user produces bothauction.bid.updated(old) andauction.bid.created(new) — order them byserverSequence.
13. Error handling quick reference¶
| Condition | Envelope |
|---|---|
Unknown event type | error → data.code, data.message |
| Schema validation failure | error / command.rejected (BAD_REQUEST) |
| Not authorized (permission) | error / command.rejected (FORBIDDEN) |
| Not enrolled | error / command.rejected (FORBIDDEN) |
| Unpaid cycle invoice | error / command.rejected (BAD_REQUEST) |
| Auction not live / inactive cycle | command.failed (AUCTION_NOT_LIVE / AUCTION_ENDED) |
| Bid below min / above max / invalid step | command.failed (BID_NOT_IMPROVING / INVALID_AMOUNT) |
| Idempotency reuse with different amount | command.failed (DUPLICATE_COMMAND) |
| Rate limit exceeded | error / command.rejected |
For the full auction.bid.place errorCode list see §9.2.
Never surface raw server messages verbatim; map errorCode to localized user copy.