Skip to main content

Event-Driven APIs: Webhooks, WebSockets & SSE 2026

·APIScout Team
Share:

Event-Driven APIs: Webhooks, WebSockets, SSE, and Async Patterns

REST APIs are request-response: the client asks, the server answers. But many real-world use cases need the server to push data to the client — payment confirmations, live chat, stock prices, build status updates. Event-driven APIs flip the model: the server notifies the client when something happens.

The pattern you choose matters operationally. WebSockets require sticky load balancer sessions and a pub/sub backplane to scale across multiple servers. SSE is stateless-friendly and works with standard HTTP infrastructure. Webhooks are the simplest to operate at scale but require your consumers to run internet-accessible servers. Async request-reply is the right pattern when the response takes seconds or minutes and you don't want the client waiting on an open connection. Understanding these trade-offs upfront prevents expensive architectural rewrites later.

The Four Patterns

PatternDirectionConnectionBest For
WebhooksServer → ServerHTTP callbackBackend notifications, integrations
WebSocketsBidirectionalPersistent TCPChat, gaming, collaborative editing
SSEServer → ClientPersistent HTTPLive feeds, dashboards, notifications
Async Request-ReplyClient → Server → ClientPolling or callbackLong-running operations

1. Webhooks

How They Work

Your API sends an HTTP POST to the customer's URL when an event occurs:

Event occurs (e.g., payment succeeds)
  → Your API sends POST to customer's webhook URL
  → Customer's server processes the event
  → Customer returns 200 OK (acknowledgment)

Implementation

Provider side (sending webhooks):

1. Customer registers a webhook URL: https://their-app.com/webhooks
2. Event occurs in your system
3. Build webhook payload with event data
4. Sign the payload (HMAC-SHA256)
5. POST to customer's URL with signature header
6. If non-2xx response: retry with exponential backoff
7. After N failures: disable webhook, notify customer

Payload format:

{
  "id": "evt_a1b2c3d4",
  "type": "payment.succeeded",
  "created": "2026-03-08T14:30:00Z",
  "data": {
    "payment_id": "pay_xyz",
    "amount": 9900,
    "currency": "usd"
  }
}

Security

Sign every webhook so consumers can verify it came from you:

Signature = HMAC-SHA256(webhook_secret, raw_request_body)
Header: X-Webhook-Signature: sha256=a1b2c3d4...

Consumer verifies:

expected = HMAC-SHA256(their_webhook_secret, raw_body)
if (expected !== received_signature) reject

Additional security measures:

  • Include a timestamp to prevent replay attacks
  • Allow customers to rotate webhook secrets
  • Use HTTPS only (never send webhooks over HTTP)
  • Include an idempotency key so consumers can deduplicate

Retry Strategy

Attempt 1: Immediate
Attempt 2: 1 minute later
Attempt 3: 5 minutes later
Attempt 4: 30 minutes later
Attempt 5: 2 hours later
Attempt 6: 8 hours later
Attempt 7: 24 hours later → Give up, mark webhook as failing

After 3 consecutive days of failures: Disable the webhook and email the customer.

When to Use Webhooks

✅ Payment notifications, order updates, CI/CD build results, CRM events ❌ Real-time chat, live data streams, interactive experiences

2. WebSockets

How They Work

Persistent bidirectional connection over TCP:

Client: GET /ws (Upgrade: websocket)
Server: 101 Switching Protocols
── Connection established ──
Client → Server: { "type": "subscribe", "channel": "chat-123" }
Server → Client: { "type": "message", "text": "Hello!" }
Client → Server: { "type": "message", "text": "Hi back!" }
Server → Client: { "type": "message", "text": "How are you?" }
── Connection persists until explicitly closed ──

Implementation Considerations

Connection management:

ConcernSolution
Connection dropsAutomatic reconnect with backoff
AuthenticationAuth on initial handshake (token in query param or first message)
HeartbeatPing/pong every 30 seconds to detect dead connections
ScalingSticky sessions or pub/sub backplane (Redis)
Load balancingLayer 7 LB with WebSocket support (nginx, HAProxy)

Message format:

{
  "type": "event_type",
  "id": "msg_123",
  "timestamp": "2026-03-08T14:30:00Z",
  "data": { ... }
}

Scaling WebSockets

WebSocket connections are stateful — each connection lives on a specific server. Scaling requires:

Clients ↔ Load Balancer (sticky sessions)
             ↓
    Server 1    Server 2    Server 3
         ↓         ↓         ↓
         └─── Redis Pub/Sub ──┘
              (broadcast messages across servers)

Connection limits:

ScaleConnections per ServerServers Needed
Small10K1
Medium50K2-5
Large100K+10+ with auto-scaling

When to Use WebSockets

✅ Chat, gaming, collaborative editing, live trading, real-time multiplayer ❌ One-directional server pushes, infrequent updates, server-to-server

3. Server-Sent Events (SSE)

How They Work

One-directional stream from server to client over HTTP:

Client: GET /events (Accept: text/event-stream)
Server: 200 OK (Content-Type: text/event-stream)
── Stream opens ──
data: {"type": "update", "price": 150.25}

data: {"type": "update", "price": 150.30}

data: {"type": "update", "price": 149.95}
── Stream stays open indefinitely ──

SSE Format

event: price-update
id: 42
retry: 3000
data: {"symbol": "AAPL", "price": 150.25}

event: news
id: 43
data: {"headline": "Market opens higher"}
FieldPurpose
eventEvent type (for client-side filtering)
idLast event ID (for reconnection — "give me events after 43")
retryReconnection delay in milliseconds
dataEvent payload (can be multi-line)

SSE vs. WebSocket

DimensionSSEWebSocket
DirectionServer → Client onlyBidirectional
ProtocolHTTPWebSocket (TCP)
ReconnectionAutomatic (built into spec)Manual implementation
Data formatText onlyText or binary
Browser supportAll modern browsersAll modern browsers
Through proxiesWorks (it's HTTP)Sometimes blocked
ScalingStateless-friendlyRequires sticky sessions
Max connections6 per domain (HTTP/1.1), unlimited (HTTP/2)No limit

Use SSE when you only need server-to-client pushes. It's simpler, more reliable, and works better with existing HTTP infrastructure.

When to Use SSE

✅ Live dashboards, notification feeds, AI streaming responses, build logs, stock tickers ❌ Chat (need bidirectional), binary data, gaming

4. Async Request-Reply

How It Works

For long-running operations that can't return immediately:

Client: POST /api/reports/generate
Server: 202 Accepted
        { "job_id": "job_123", "status_url": "/api/jobs/job_123" }

── Client polls status ──
Client: GET /api/jobs/job_123
Server: { "status": "processing", "progress": 45 }

Client: GET /api/jobs/job_123
Server: { "status": "completed", "result_url": "/api/reports/abc.pdf" }

Implementation Patterns

Pattern 1: Polling

1. Client submits request → 202 Accepted with job ID
2. Client polls status endpoint every N seconds
3. Server returns progress updates
4. When complete: server returns result or download URL

Pattern 2: Webhook Callback

1. Client submits request with callback URL → 202 Accepted
2. Server processes asynchronously
3. Server POSTs result to callback URL when done
4. No polling needed

Pattern 3: WebSocket Notification

1. Client connects WebSocket and submits request
2. Server processes asynchronously
3. Server pushes progress updates and final result via WebSocket

Response Codes

CodeMeaningUse
202 AcceptedRequest accepted for processingInitial submission
200 OK with statusJob still processingStatus poll
303 See OtherJob complete, redirect to resultCompletion (with Location header)

When to Use Async Request-Reply

✅ Report generation, video processing, data imports, ML inference, batch operations ❌ Simple CRUD, instant responses, real-time interaction

Security Considerations by Pattern

Each event-driven pattern has distinct security concerns that request-response APIs don't surface as sharply.

Webhooks: The receiving endpoint is publicly accessible on the internet. Always verify the webhook signature before processing the payload — this prevents attackers from sending fake events to your endpoint. Replay attacks (replaying a legitimate request later) are mitigated by rejecting events with timestamps older than 5 minutes. Never process a webhook without signature verification.

WebSockets: Authentication happens at connection time, not per-message. Tokens that expire during a long-lived connection require either a token refresh mechanism or connection reconnection. Authorize at the message level too, especially in multi-tenant systems where one connection might subscribe to multiple channels.

SSE: SSE uses standard HTTP, so standard auth mechanisms (cookies, headers) work. One complication: EventSource in browsers doesn't support custom headers — you must use cookies or query parameters for auth, or proxy the SSE endpoint through middleware that adds authentication. In Next.js, using server components or middleware to validate the session before streaming is the standard approach.

Async Request-Reply: The job status endpoint must be scoped to the requesting user — returning any job's status to any authenticated user would be an IDOR (Insecure Direct Object Reference) vulnerability. Always scope job lookups to the owning user or organization.

Choosing the Right Pattern

Does the server need to notify the client?
  ├── No → Standard REST (request-response)
  └── Yes
      ├── Server-to-server notification?
      │   └── Webhooks
      ├── Client needs live updates?
      │   ├── Bidirectional? → WebSocket
      │   └── Server-to-client only? → SSE
      └── Long-running operation?
          └── Async Request-Reply

Pattern Comparison Summary

CriteriaWebhooksWebSocketSSEAsync Reply
ComplexityMediumHighLowMedium
InfrastructureSimpleComplex (stateful)SimpleMedium
Scaling difficultyLowHighMediumLow
LatencySecondsMillisecondsMillisecondsSeconds-minutes
ReliabilityRetry-basedConnection-dependentAuto-reconnectPolling-based
Firewall-friendlyYesSometimes noYesYes

Implementing Webhooks in Node.js

Here's a production-ready webhook sender pattern you can adapt for any Node.js API:

// lib/webhooks.ts
import crypto from 'crypto';

interface WebhookDelivery {
  endpoint: string;
  secret: string;
  eventType: string;
  payload: Record<string, unknown>;
  maxRetries?: number;
}

async function sendWebhook({ endpoint, secret, eventType, payload, maxRetries = 5 }: WebhookDelivery) {
  const body = JSON.stringify({
    id: `evt_${crypto.randomUUID()}`,
    type: eventType,
    created: new Date().toISOString(),
    data: payload,
  });

  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signature = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  const delays = [0, 60, 300, 1800, 7200]; // seconds: 0, 1m, 5m, 30m, 2h

  for (let attempt = 0; attempt < Math.min(maxRetries, delays.length); attempt++) {
    if (delays[attempt] > 0) {
      await new Promise(r => setTimeout(r, delays[attempt] * 1000));
    }

    try {
      const res = await fetch(endpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Webhook-Signature': `sha256=${signature}`,
          'X-Webhook-Timestamp': timestamp,
          'X-Webhook-Id': body.includes('evt_') ? body.match(/evt_[^"]+/)?.[0] ?? '' : '',
        },
        body,
        signal: AbortSignal.timeout(10_000),
      });

      if (res.ok) return { success: true, attempt };

      // 4xx (except 429) are not retried — the endpoint rejected us
      if (res.status >= 400 && res.status < 500 && res.status !== 429) {
        return { success: false, reason: `rejected: ${res.status}` };
      }
    } catch {
      // Network error or timeout — retry
    }
  }

  return { success: false, reason: 'max retries exceeded' };
}

The timestamp in the signature prevents replay attacks. Consumers should reject webhook deliveries where the timestamp is more than 5 minutes old.

Hybrid Architecture

Most production systems combine multiple patterns:

Payment API:
  - REST for creating charges (sync)
  - Webhooks for payment confirmations (async server-to-server)
  - SSE for dashboard live updates (async server-to-client)

Collaboration App:
  - REST for CRUD operations (sync)
  - WebSocket for real-time editing (bidirectional)
  - SSE for presence indicators (server-to-client)
  - Webhooks for third-party integrations (server-to-server)

Designing with multiple patterns is not over-engineering — it's recognizing that different parts of your system have different latency, reliability, and directionality requirements. The most common mistake is using a single pattern (usually REST polling or WebSockets) for every use case, which leads to either resource waste or missing real-time capability. Start with the simplest pattern that meets your requirements. Add complexity only when scale or functionality demands it.

Implementing SSE in Next.js

SSE is straightforward to implement using the App Router's streaming response support:

// app/api/events/route.ts
export async function GET(req: Request) {
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      // Send an initial event to confirm the connection
      controller.enqueue(encoder.encode('event: connected\ndata: {}\n\n'));

      // Subscribe to your event source (Redis pub/sub, DB changes, etc.)
      const cleanup = subscribeToEvents((event) => {
        const data = `event: ${event.type}\nid: ${event.id}\ndata: ${JSON.stringify(event.data)}\n\n`;
        controller.enqueue(encoder.encode(data));
      });

      // Heartbeat to keep the connection alive through proxies
      const heartbeat = setInterval(() => {
        controller.enqueue(encoder.encode(': heartbeat\n\n'));
      }, 30_000);

      // Clean up when the client disconnects
      req.signal.addEventListener('abort', () => {
        clearInterval(heartbeat);
        cleanup();
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'Connection': 'keep-alive',
      'X-Accel-Buffering': 'no', // Disable nginx buffering
    },
  });
}
// Client-side usage
const eventSource = new EventSource('/api/events');

eventSource.addEventListener('order-updated', (e) => {
  const order = JSON.parse(e.data);
  updateUI(order);
});

eventSource.onerror = () => {
  // EventSource reconnects automatically — this fires on each reconnection attempt
  console.log('Reconnecting...');
};

The X-Accel-Buffering: no header is important for deployments behind nginx — without it, nginx buffers SSE data and the stream appears to not work.

Common Mistakes

MistakeImpactFix
Using WebSocket when SSE sufficesOver-engineered, harder to scaleSSE for server-push only
Polling instead of webhooksWasted resources, higher latencyImplement webhooks
No webhook retry logicLost events on temporary failuresExponential backoff + DLQ
WebSocket without heartbeatZombie connections consume resourcesPing/pong every 30 seconds
No reconnection logicDropped connections stay droppedAuto-reconnect with backoff
Unsigned webhooksSecurity vulnerabilityHMAC-SHA256 signatures
Missing X-Accel-Buffering: noSSE blocked by nginxAdd the header on SSE responses

Methodology

Choosing the right event-driven pattern is an architectural decision that affects infrastructure cost, development complexity, and operational reliability for the lifetime of your product. The comparison tables and decision tree in this guide reflect real production trade-offs — not theoretical ones. Start with the simplest pattern that meets your requirements, and validate your choice against expected scale before committing to infrastructure.

Pattern comparison data based on industry documentation from IETF (WebSocket RFC 6455), W3C (SSE specification), and published production architecture case studies. Performance and scaling characteristics sourced from cloud provider documentation (AWS, Cloudflare, Vercel) and open-source community benchmarks. Code examples use Node.js 20 LTS and Next.js App Router (2026 stable releases).

Building event-driven APIs? Explore async API patterns and tools on APIScout — comparisons, guides, and developer resources.

Related: Best Event-Driven Architecture APIs 2026, Motia API Workflows: Steps & Events 2026, Motia: Event-Driven API Workflows in 2026

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.