Event-Driven APIs: Webhooks, WebSockets & SSE 2026
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
| Pattern | Direction | Connection | Best For |
|---|---|---|---|
| Webhooks | Server → Server | HTTP callback | Backend notifications, integrations |
| WebSockets | Bidirectional | Persistent TCP | Chat, gaming, collaborative editing |
| SSE | Server → Client | Persistent HTTP | Live feeds, dashboards, notifications |
| Async Request-Reply | Client → Server → Client | Polling or callback | Long-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:
| Concern | Solution |
|---|---|
| Connection drops | Automatic reconnect with backoff |
| Authentication | Auth on initial handshake (token in query param or first message) |
| Heartbeat | Ping/pong every 30 seconds to detect dead connections |
| Scaling | Sticky sessions or pub/sub backplane (Redis) |
| Load balancing | Layer 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:
| Scale | Connections per Server | Servers Needed |
|---|---|---|
| Small | 10K | 1 |
| Medium | 50K | 2-5 |
| Large | 100K+ | 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"}
| Field | Purpose |
|---|---|
event | Event type (for client-side filtering) |
id | Last event ID (for reconnection — "give me events after 43") |
retry | Reconnection delay in milliseconds |
data | Event payload (can be multi-line) |
SSE vs. WebSocket
| Dimension | SSE | WebSocket |
|---|---|---|
| Direction | Server → Client only | Bidirectional |
| Protocol | HTTP | WebSocket (TCP) |
| Reconnection | Automatic (built into spec) | Manual implementation |
| Data format | Text only | Text or binary |
| Browser support | All modern browsers | All modern browsers |
| Through proxies | Works (it's HTTP) | Sometimes blocked |
| Scaling | Stateless-friendly | Requires sticky sessions |
| Max connections | 6 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
| Code | Meaning | Use |
|---|---|---|
202 Accepted | Request accepted for processing | Initial submission |
200 OK with status | Job still processing | Status poll |
303 See Other | Job complete, redirect to result | Completion (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
| Criteria | Webhooks | WebSocket | SSE | Async Reply |
|---|---|---|---|---|
| Complexity | Medium | High | Low | Medium |
| Infrastructure | Simple | Complex (stateful) | Simple | Medium |
| Scaling difficulty | Low | High | Medium | Low |
| Latency | Seconds | Milliseconds | Milliseconds | Seconds-minutes |
| Reliability | Retry-based | Connection-dependent | Auto-reconnect | Polling-based |
| Firewall-friendly | Yes | Sometimes no | Yes | Yes |
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
| Mistake | Impact | Fix |
|---|---|---|
| Using WebSocket when SSE suffices | Over-engineered, harder to scale | SSE for server-push only |
| Polling instead of webhooks | Wasted resources, higher latency | Implement webhooks |
| No webhook retry logic | Lost events on temporary failures | Exponential backoff + DLQ |
| WebSocket without heartbeat | Zombie connections consume resources | Ping/pong every 30 seconds |
| No reconnection logic | Dropped connections stay dropped | Auto-reconnect with backoff |
| Unsigned webhooks | Security vulnerability | HMAC-SHA256 signatures |
Missing X-Accel-Buffering: no | SSE blocked by nginx | Add 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