Webhook Reliability and Deduplication
Handling retries, deduplication, ordering guarantees, and replay scenarios.
Webhook delivery is at-least-once, meaning events may be delivered more than once. This guide covers best practices for handling retries, deduplication, and ensuring idempotent processing.
Delivery Guarantees
At-Least-Once Delivery
Aidelly guarantees at-least-once delivery:
- Your webhook endpoint is called at least one time for each event
- Your endpoint may receive the same event multiple times
- Events may arrive out of order
- We do not track which events you've received (consumers are responsible for dedup)
This is the industry standard because it's more reliable than "exactly-once" and avoids losing events due to network issues.
Retry Policy
When your webhook endpoint is unreachable or returns a non-2xx status:
| Attempt | Delay Before Attempt | Total Time |
|---|---|---|
| 1 | Immediate | 0 ms |
| 2 | 150 ms | 150 ms |
| 3 | 300 ms | 450 ms |
After 3 failed attempts, the webhook delivery is marked failed and no further retries occur. Monitor your delivery logs to catch failed webhooks. Total delivery attempt window spans less than 1 second.
Retry Conditions
We retry if:
- Your endpoint returns a non-2xx HTTP status code
- Your endpoint doesn't respond within 10 seconds
- Your endpoint times out or the connection drops
- Your endpoint returns a 5xx error
We do not retry if:
- Your endpoint returns 2xx (success)
- Your endpoint returns 4xx (client error — you acknowledge the issue)
Deduplication Strategy
Implement deduplication by tracking event IDs:
// Redis-backed dedup store (any persistent store works)
const seenEvents = new Set<string>();
export async function handleWebhook(req: Request): Promise<Response> {
const payload = await req.json();
// Extract event ID
const eventId = payload.id;
// Check if we've seen this event
if (seenEvents.has(eventId)) {
return Response.json({ success: true }, { status: 200 });
}
// Process the event
await processEvent(payload);
// Mark as seen
seenEvents.add(eventId);
// Return 2xx to prevent retries
return Response.json({ success: true }, { status: 200 });
}Event ID Structure
Each webhook event includes a unique id field:
{
"id": "evt_550e8400e29b41d4a716446655440000",
"type": "post.published",
"occurred_at": "2026-02-18T17:30:02Z",
"workspace_id": "ws_abc123",
"brand_id": "br_xyz789",
"data": {
"post_id": "post_uuid",
"status": "completed"
}
}Use the id as your deduplication key. Event IDs are:
- Unique across all events in your workspace
- Stable (same
idon retries) - Permanently available for lookup
The type field indicates the event type (e.g. post.published, post.failed). Use occurred_at to track event timing.
Dedup with Database
For more robust deduplication, store event IDs in your database:
export async function handleWebhook(req: Request): Promise<Response> {
const payload = await req.json();
// Start a database transaction
const tx = db.transaction();
try {
// Check if we've processed this event
const existing = await tx.query(
'SELECT id FROM processed_webhooks WHERE event_id = ?',
[payload.id]
);
if (existing.length > 0) {
return Response.json({ success: true }, { status: 200 });
}
// Process the event
await processEvent(payload, tx);
// Record that we've processed it
await tx.query(
'INSERT INTO processed_webhooks (event_id, received_at) VALUES (?, ?)',
[payload.id, new Date().toISOString()]
);
await tx.commit();
return Response.json({ success: true }, { status: 200 });
} catch (error) {
await tx.rollback();
throw error;
}
}Ordering Guarantees
Events from the same resource (e.g., the same post) are delivered in order:
post.created (post_id=abc)
post.scheduled (post_id=abc)
post.published (post_id=abc)Events from different resources may arrive out of order:
post.published (post_id=abc) — arrives first
post.published (post_id=xyz) — arrives secondTo handle out-of-order events, check timestamps and resource state:
export async function handleWebhook(req: Request): Promise<Response> {
const payload = await req.json();
const post = await db.query(
'SELECT status, published_at FROM posts WHERE id = ?',
[payload.data.post_id]
);
// If the post's state is newer than the webhook event, skip it
if (
post.published_at &&
new Date(post.published_at) > new Date(payload.created_at)
) {
return Response.json({ success: true }, { status: 200 });
}
// Process the event
await updatePostStatus(payload);
return Response.json({ success: true }, { status: 200 });
}Signature Verification
Every webhook includes an Aidelly-Signature header. Verify it to ensure the payload came from Aidelly:
import crypto from 'crypto';
function verifyWebhookSignature(
payload: string, // raw request body (string, not parsed JSON)
signature: string, // Aidelly-Signature header value
secret: string // your webhook signing secret
): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(`sha256=${expected}`)
);
}
export async function handleWebhook(req: Request): Promise<Response> {
const rawBody = await req.text();
const signature = req.headers.get('Aidelly-Signature');
if (
!signature ||
!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET!)
) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = JSON.parse(rawBody);
// Process webhook...
}Idempotent Event Processing
Always implement idempotent event handlers — processing the same event twice should have the same effect as processing it once.
Good (idempotent):
// Use upsert (update or insert)
await db.query(
'INSERT INTO posts (id, status, published_at) VALUES (?, ?, ?) ' +
'ON CONFLICT (id) DO UPDATE SET status = ?, published_at = ?',
[postId, 'published', publishedAt, 'published', publishedAt]
);Bad (not idempotent):
// Unconditional insert — fails if event is retried
await db.query(
'INSERT INTO posts (id, status, published_at) VALUES (?, ?, ?)',
[postId, 'published', publishedAt]
);
// Increment counters — double-counts on retry
await db.query(
'UPDATE analytics SET total_posts = total_posts + 1 WHERE user_id = ?',
[userId]
);Monitoring Webhook Health
Track webhook delivery metrics to catch issues early:
export async function handleWebhook(req: Request): Promise<Response> {
const payload = await req.json();
try {
await processEvent(payload);
// Log successful delivery
await db.query(
'INSERT INTO webhook_logs (event_id, status, delivered_at) VALUES (?, ?, ?)',
[payload.id, 'success', new Date().toISOString()]
);
return Response.json({ success: true }, { status: 200 });
} catch (error) {
// Log failed delivery
await db.query(
'INSERT INTO webhook_logs (event_id, status, error, attempted_at) VALUES (?, ?, ?, ?)',
[payload.id, 'failed', error.message, new Date().toISOString()]
);
// Return 5xx to trigger retry
return Response.json({ error: error.message }, { status: 500 });
}
}Monitor for:
- Spike in failed deliveries
- Webhook endpoint latency increasing
- Timeout rates increasing
These are signs that your endpoint needs optimization or scaling.
Event Types
Webhook events include:
post.created— post created (draft)post.scheduled— post confirmed scheduledpost.published— post successfully publishedpost.failed— post failed to publishpost.canceled— post canceledapproval.batch_created— approval batch createdapproval.batch_approved— batch approvedapproval.item_approved— item approvedapproval.item_rejected— item rejectedautomation.triggered— automation rule triggeredinbox.message_received— inbox message receivedinbox.message_responded— inbox message responded to
See Webhooks for payload shapes and subscription details.
Common Patterns
Batch Processing
Process events in batches for efficiency:
const eventQueue: WebhookPayload[] = [];
let flushTimer: NodeJS.Timeout | null = null;
export async function handleWebhook(req: Request): Promise<Response> {
const payload = await req.json();
eventQueue.push(payload);
// Flush every 100 events or 5 seconds
if (eventQueue.length >= 100) {
await flushEvents();
} else if (!flushTimer) {
flushTimer = setTimeout(() => {
flushEvents().finally(() => {
flushTimer = null;
});
}, 5000);
}
return Response.json({ success: true }, { status: 202 });
}
async function flushEvents() {
if (eventQueue.length === 0) return;
const batch = eventQueue.splice(0);
await db.query('INSERT INTO events (payload) VALUES (?)', [
JSON.stringify(batch),
]);
}Dead Letter Queue
For events that fail processing repeatedly:
export async function handleWebhook(req: Request): Promise<Response> {
const payload = await req.json();
try {
await processEvent(payload);
return Response.json({ success: true }, { status: 200 });
} catch (error) {
// Check retry count (you can store this in a database)
const retryCount = getRetryCount(payload.id);
if (retryCount >= 5) {
// Move to dead letter queue for manual review
await db.query(
'INSERT INTO webhook_dead_letter (event_id, payload, error) VALUES (?, ?, ?)',
[payload.id, JSON.stringify(payload), error.message]
);
// Alert the team
await notifySlack(`Failed webhook: ${payload.id}`);
return Response.json({ success: true }, { status: 200 });
}
// Let Aidelly retry
return Response.json({ error: error.message }, { status: 500 });
}
}