Aidelly Docs
REST API

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:

AttemptDelay Before AttemptTotal Time
1Immediate0 ms
2150 ms150 ms
3300 ms450 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 id on 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 second

To 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 scheduled
  • post.published — post successfully published
  • post.failed — post failed to publish
  • post.canceled — post canceled
  • approval.batch_created — approval batch created
  • approval.batch_approved — batch approved
  • approval.item_approved — item approved
  • approval.item_rejected — item rejected
  • automation.triggered — automation rule triggered
  • inbox.message_received — inbox message received
  • inbox.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 });
  }
}