Skip to content

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) with recentBids + 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:

wss://<host>/ws
  • 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 (and auction:staff for 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:

  1. Authorizes an eligible active enrollment. Watching does not require payment.
  2. Joins the room, subscribes to realtime events, and opens a per-connection audience session.
  3. Sends ack (data.eventType = "auction.subscribe").
  4. Sends auction.connected with data.payload = { connectionId, heartbeatIntervalMs }.
  5. Sends auction.state.snapshot — the full snapshot (§4.3).
  6. Sends auction.audience.snapshot with 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)

{
  "id": "req-124",
  "type": "auction.unsubscribe",
  "data": { "auctionId": "auction-1" }
}

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):

  1. Reconnect to /ws.
  2. Send auction.subscribe with the auctionId (fetch via REST; row guaranteed before READY/LIVE) and the last lastSequence you 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 = an AuctionBidActivityItem with status: "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 leading AuctionBidActivityItem with status: "OUTBID", isWinningBid: false, bidRank: null.
  • Arrives immediately before the auction.bid.created for the higher bid (both share the same createdAt batch; their serverSequence values 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.

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-level data.serverSequence) that is strictly increasing within a cycle. Order the visible feed by it (descending in the UI).
  • bidAmount comparisons, 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.id on 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 + 1 as loss by itself. After actual transport loss or inconsistent state, resubscribe with the last applied/reported high-water cursor and advance to replay.lastSequence.
  • recentBids in a reconnect snapshot is a tail, not a diff. Rebuild from replay.events when 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:

  • amountMinor is 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: 100 per window) for auction.bid.place.
  • PostgreSQL serializes concurrent requests on the auction row. The server returns one terminal ack after commit (accepted or replayed), or a correlated error for 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.snapshot arrives once per subscribe with viewerCount, joinedParticipantCount, onlineParticipantCount, and bidderCount.
  • auction.audience.changed carries 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 immutable FLOOR admission, 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;
    };

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 both auction.bid.updated (old) and auction.bid.created (new) — order them by serverSequence.

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.