Files
nick-doc/03 - API Reference/Socket Events.md
Siavash Sameni 9698ec5809 docs: align API reference and data model docs with code reality
API Reference (9 files updated):
- Marketplace API: corrected offer endpoints (scoped under /purchase-requests/:id/offers),
  marked phantom /search /stats /seller/:sellerId /withdraw routes as NOT IMPLEMENTED,
  documented PUT→PATCH mismatches, removed invalid SellerOffer 'active' status
- Dispute API: corrected resolve schema (action enum), categories (no 'fraud'),
  removed 'under_review' status, added security callouts (3 unguarded endpoints),
  route shadowing documented, all socket events marked as TODO stubs
- Notification API: corrected mark-all-read method+path, fixed broken GET /:id,
  added unread-count-update event, 90-day TTL documented
- Payment API: /create→/save, removed 10+ phantom endpoints, fixed release/refund
  paths (no /shkeeper/ segment), added 3 unauthenticated endpoint security warnings,
  stats undercounting documented, export privilege gap documented
- Authentication API: 8-digit→6-digit code, no-complexity warning on reset-with-code,
  rate limiter counts all attempts, passkey stub claims removed, deleteAccount bug noted
- Admin API: PUT→PATCH bug documented, wrong status values documented, hard vs soft
  delete clarified, scanner no-auth security bug, 3 NOT IMPLEMENTED endpoints
- Chat API: file upload wrong endpoint bug, archive PUT→PATCH bug, rate limits added
- Points API: corrected redeem schema, referral triggers on 'completed' only,
  leaderboard period ignored, removed 'refund' PointTransaction type
- Socket Events: removed request-cancelled, notification-read; added unread-count-update;
  dispute events all stubs; referral-signup is auth-domain not points-domain

Data Models (3 files updated):
- SellerOffer: removed 'active' from status enum, withdrawOffer() is dead code
- PurchaseRequest: added pending_payment/active statuses, added 'urgent' urgency,
  corrected description minimum (5 chars), removed finalized/archived
- Dispute: corrected action enum, categories (no fraud), removed under_review,
  security callout on unguarded status/resolve endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:57:47 +04:00

9.5 KiB

title, tags
title tags
Socket Events
api
socket
realtime
reference

Socket Events

Last updated: 2026-05-29 — aligned with code (see Doc vs Code Audit Report)

The backend runs a Socket.IO server on the same HTTP port as the REST API. It is initialised in backend/src/app.ts and exposed globally as global.io. Helper functions for emitting events from services live in backend/src/infrastructure/socket/socketService.ts:

emitGlobalEvent(event, data);          // io.emit
emitToRoom(room, event, data);         // io.to(room).emit
setSocketServer(server);
getSocketServer();

Connection

import { io } from "socket.io-client";

const socket = io(process.env.NEXT_PUBLIC_API_URL!, {
  withCredentials: true,
  transports: ["websocket"],
});

The CORS policy mirrors the REST API: only FRONTEND_URL is allowed, and credentials are enabled. There is no token-based handshake — the server identifies users by the join-* events the client emits after connecting. (A future improvement would authenticate via the JWT on handshake; for now, ownership is checked at the REST layer when a mutation triggers an event.)

The server logs 🔌 User connected: <socketId> on connect and 🔌 User disconnected: <socketId> on disconnect. There is no broadcast user-offline on disconnect — see "Online status" below.

Rooms

Rooms are the targeting mechanism. The client joins/leaves rooms with these events:

Client → Server Room joined
join-user-room (userId) user-<userId>
join-request-room (requestId) request-<requestId>
leave-request-room (requestId) leaves request-<requestId>
join-seller-room (sellerId) seller-<sellerId> + sellers (global)
leave-seller-room (sellerId) leaves both
join-buyer-room (buyerId) buyer-<buyerId> + buyers (global)
leave-buyer-room (buyerId) leaves both
join-chat-room (chatId) chat-<chatId>
leave-chat-room (chatId) leaves chat-<chatId>
user-online (userId) joins user-<userId>, broadcasts user-status-change
typing-start ({ chatId, userId, userName }) broadcasts user-typing to chat-<chatId>
typing-stop ({ chatId, userId }) broadcasts user-typing (isTyping=false)

Joining a room is unauthenticated — clients are expected to only join their own rooms. Sensitive data is filtered at the REST layer that emits the event.

Server → Client events

Grouped by the service that emits them.

Marketplace

Event Room Payload Source
new-purchase-request sellers (shared global room) PurchaseRequest document marketplaceController.createPurchaseRequest
new-offer buyer-<buyerId> { requestId, offer, sellerId } marketplaceController.createSellerOffer
seller-offer-update seller-<sellerId> (and global on payment confirm) { sellerId, requestId, eventType: "payment-completed" | "offer-rejected" | "offer-accepted", data: { offerId, isSelected, paymentId?, transactionHash?, reason? } } marketplaceController, shkeeperRoutes, shkeeperWebhook, SellerOfferService
purchase-request-update request-<requestId> { type, requestId, status?, paymentId?, txHash?, provider? } marketplaceController, PurchaseRequestService, shkeeperRoutes, paymentCoordinator
transaction-completed user-<buyerId>, user-<sellerId> { requestId, paymentId, amount, currency } marketplaceController
delivery-code-generated request-<requestId> { requestId, deliveryCode } (only the seller UI uses this) DeliveryService
delivery-update request-<requestId> { requestId, status, carrier?, trackingNumber? } DeliveryService
delivery-confirmed request-<requestId> { requestId } DeliveryService
buyer-confirmed-delivery user-<sellerId> { requestId, buyerId } DeliveryService
template-checkout-payment-confirmed global + template-checkout-<id> { checkoutId, requestIds, paymentId } templateCheckoutWebhook, paymentCoordinator
template-checkout-payment-pending global { checkoutId } templateCheckoutWebhook
template-checkout-payment-failed global { checkoutId, reason } templateCheckoutWebhook

Note: There is no request-cancelled event. When a purchase request is cancelled, PurchaseRequestService emits purchase-request-update with eventType: 'status-changed' to the request-<requestId> room. Any code listening for request-cancelled will never fire.

Payment

Event Room Payload Source
payment-created global { paymentId, provider, requestId, buyerId, sellerId, amount, currency } shkeeperService, decentralizedPaymentService
payment-received user-<sellerId> { purchaseRequestId, amount, currency, buyerId } paymentRoutes, marketplace/routes
payment-update global + room-specific { paymentId, status, escrowState?, txHash? } paymentCoordinator
payout-created global { payoutId, taskId, sellerId, amount, currency } shkeeperPayoutService
payout-completed global, user-<sellerId> { payoutId, taskId, txHash } shkeeperPayoutService, decentralizedPaymentService
payout-updated global { payoutId, status } shkeeperPayoutService

Chat

Event Room Payload
new-message chat-<chatId> { chatId, message: { _id, content, senderId, createdAt, attachments? }, senderId }
messages-read chat-<chatId> { chatId, userId, upToMessageId, modifiedCount }
message-edited chat-<chatId> { chatId, messageId, content, editedAt }
message-deleted chat-<chatId> { chatId, messageId, deletedAt }
participants-added chat-<chatId> { chatId, addedUserIds }
participant-removed chat-<chatId> { chatId, removedUserId }
user-typing chat-<chatId> { userId, userName?, isTyping }
user-status-change broadcast { userId, status: "online", lastSeen }

Sources: ChatService.ts, chatController.ts, and app.ts socket handlers.

Note: There is no notification-read event. Cross-tab unread badge synchronisation is handled by unread-count-update (see Notification table below), not by a dedicated read event.

Notification

Event Room Payload
new-notification user-<userId> { notification: { _id, type, title, body, data, createdAt } }
unread-count-update user-<userId> { unreadCount }

Source: NotificationService.ts.

unread-count-update is the canonical cross-tab sync mechanism for the notification badge. It is emitted whenever the unread count changes (new notification or mark-as-read).

Points

Event Room Payload Source
level-up user-<userId> { oldLevel, newLevel, lifetimePoints, perks } PointsService
referral-reward user-<referrerId> { referredUserId, points, transactionId } PointsService
referral-signup user-<referrerId> { referredUserId, name, joinedAt } authController (auth domain, not PointsService)

Note on referral-signup — This event is emitted by authController when a referred user completes registration, not by PointsService. It belongs to the authentication domain. PointsService emits only level-up and referral-reward.

Disputes

⚠️ TODO stubsDisputeService does not currently emit any Socket.IO events. All socket event handlers in DisputeService are placeholder stubs. No real-time dispute notifications fire regardless of dispute status changes.

Online status

A client emits user-online after connecting; the server broadcasts user-status-change (status "online"). There is currently no matching offline emit on disconnect because the server does not track which userId belongs to which socketId. To implement presence, store the mapping on connect and emit user-status-change (status "offline") in the disconnect handler.

Reconnection

Socket.IO defaults are used: exponential backoff starting at 1s, capped at 5s, with jitter. The recommended client policy is:

  1. On reconnect, re-emit every join-* for the rooms the user cares about.
  2. Re-fetch any state that may have moved while disconnected (GET /api/notifications/unread-count, the active purchase request, chat history with ?before=<lastSeen>).
  3. Treat the first 5 seconds after reconnect as "catching up" in the UI.

The server does not buffer missed events; if delivery guarantees matter, fall back to REST.

Server-side helpers (for service authors)

import { emitGlobalEvent, emitToRoom } from "@/infrastructure/socket/socketService";

emitGlobalEvent("new-purchase-request", request);
emitToRoom(`request-${requestId}`, "purchase-request-update", { type: "status_changed", status });

Use emitToRoom whenever you can — it limits the broadcast surface area. Reserve emitGlobalEvent for events that genuinely need every connected client (rare).