--- title: Socket Events tags: [api, socket, realtime, reference] --- # Socket Events The backend runs a Socket.IO server on the same HTTP port as the REST API. It is initialised in [`backend/src/app.ts`](../../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`](../../backend/src/infrastructure/socket/socketService.ts): ```ts emitGlobalEvent(event, data); // io.emit emitToRoom(room, event, data); // io.to(room).emit setSocketServer(server); getSocketServer(); ``` ## Connection ```ts 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: ` on connect and `🔌 User disconnected: ` 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-` | | `join-request-room` (requestId) | `request-` | | `leave-request-room` (requestId) | leaves `request-` | | `join-seller-room` (sellerId) | `seller-` + `sellers` (global) | | `leave-seller-room` (sellerId) | leaves both | | `join-buyer-room` (buyerId) | `buyer-` + `buyers` (global) | | `leave-buyer-room` (buyerId) | leaves both | | `join-chat-room` (chatId) | `chat-` | | `leave-chat-room` (chatId) | leaves `chat-` | | `user-online` (userId) | joins `user-`, broadcasts `user-status-change` | | `typing-start` ({ chatId, userId, userName }) | broadcasts `user-typing` to `chat-` | | `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` | `PurchaseRequest` document | `marketplaceController.createPurchaseRequest` | | `new-offer` | `buyer-` | `{ requestId, offer, sellerId }` | `marketplaceController.createSellerOffer` | | `seller-offer-update` | `seller-` (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-` | `{ type, requestId, status?, paymentId?, txHash?, provider? }` | `marketplaceController`, `PurchaseRequestService`, `shkeeperRoutes`, `paymentCoordinator` | | `request-cancelled` | `user-`, `user-` | `{ requestId, reason }` | `PurchaseRequestService` | | `transaction-completed` | `user-`, `user-` | `{ requestId, paymentId, amount, currency }` | `marketplaceController` | | `delivery-code-generated` | `request-` | `{ requestId, deliveryCode }` (only the seller UI uses this) | `DeliveryService` | | `delivery-update` | `request-` | `{ requestId, status, carrier?, trackingNumber? }` | `DeliveryService` | | `delivery-confirmed` | `request-` | `{ requestId }` | `DeliveryService` | | `buyer-confirmed-delivery` | `user-` | `{ requestId, buyerId }` | `DeliveryService` | | `template-checkout-payment-confirmed` | global + `template-checkout-` | `{ checkoutId, requestIds, paymentId }` | `templateCheckoutWebhook`, `paymentCoordinator` | | `template-checkout-payment-pending` | global | `{ checkoutId }` | `templateCheckoutWebhook` | | `template-checkout-payment-failed` | global | `{ checkoutId, reason }` | `templateCheckoutWebhook` | ### Payment | Event | Room | Payload | Source | | --- | --- | --- | --- | | `payment-created` | global | `{ paymentId, provider, requestId, buyerId, sellerId, amount, currency }` | `shkeeperService`, `decentralizedPaymentService` | | `payment-received` | `user-` | `{ 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-` | `{ payoutId, taskId, txHash }` | `shkeeperPayoutService`, `decentralizedPaymentService` | | `payout-updated` | global | `{ payoutId, status }` | `shkeeperPayoutService` | ### Chat | Event | Room | Payload | | --- | --- | --- | | `new-message` | `chat-` | `{ chatId, message: { _id, content, senderId, createdAt, attachments? }, senderId }` | | `messages-read` | `chat-` | `{ chatId, userId, upToMessageId, modifiedCount }` | | `message-edited` | `chat-` | `{ chatId, messageId, content, editedAt }` | | `message-deleted` | `chat-` | `{ chatId, messageId, deletedAt }` | | `participants-added` | `chat-` | `{ chatId, addedUserIds }` | | `participant-removed` | `chat-` | `{ chatId, removedUserId }` | | `user-typing` | `chat-` | `{ userId, userName?, isTyping }` | | `user-status-change` | broadcast | `{ userId, status: "online", lastSeen }` | Sources: [`ChatService.ts`](../../backend/src/services/chat/ChatService.ts), [`chatController.ts`](../../backend/src/services/chat/chatController.ts), and `app.ts` socket handlers. ### Notification | Event | Room | Payload | | --- | --- | --- | | `new-notification` | `user-` | `{ notification: { _id, type, title, body, data, createdAt } }` | | `unread-count-update` | `user-` | `{ unreadCount }` | Source: [`NotificationService.ts`](../../backend/src/services/notification/NotificationService.ts). ### Points | Event | Room | Payload | | --- | --- | --- | | `level-up` | `user-` | `{ oldLevel, newLevel, lifetimePoints, perks }` | | `referral-reward` | `user-` | `{ referredUserId, points, transactionId }` | | `referral-signup` | `user-` | `{ referredUserId, name, joinedAt }` | Sources: [`PointsService.ts`](../../backend/src/services/points/PointsService.ts), [`authController.ts`](../../backend/src/services/auth/authController.ts). ## 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=`). 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) ```ts 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). ## Related - [[Authentication API]] (token issuance) - [[Chat API]] - [[Notification API]] - [[Marketplace API]] - [[Payment API]] - [[Real-time Architecture]]