---
title: "Email webhook deduplication: accept once, recover safely"
description: "Build an idempotent email webhook handler with durable acceptance, database uniqueness, recoverable workers, and separate protection for outgoing sends."
url: https://mailera.app/en/blog/email-webhook-deduplication
published: 2026-10-25
updated: 2026-10-25
---

# Email webhook deduplication: accept once, recover safely

Use a PostgreSQL-backed durable inbox to separate webhook acceptance from business actions. Handle concurrent deliveries without turning a worker crash into lost work or a repeated send.

## Which identifier should your consumer deduplicate on?

A repeated notification should find the same durable record, even when another request is processing it. That is the job of your webhook deduplication key. It is separate from removing duplicate addresses or duplicate messages in a mailbox.

Mailera names its event header `X-MailHub-Event` and its delivery header `X-MailHub-Delivery`. Choose identifiers by their documented meaning and scope, not by those names alone. Use the [Mailera API documentation](https://mailera.app/en/docs) when implementing the source-specific adapter.

| Identity | What it identifies | How to use it |
| --- | --- | --- |
| Logical event | One occurrence that may be delivered repeatedly | Preferred dedupe identity, stable across retries |
| Delivery attempt | One attempt to deliver a notification | Diagnostics; use for dedupe only if the contract establishes stable event semantics |
| Mailbox and message | The resource involved | Context for processing, not a complete event identity |
| Business action | An intended operation, such as an approved reply | Separate protection against repeating the action |

Scope the logical event key to its source namespace. Include subscription identity when the source contract requires it, and decide separately whether overlapping subscriptions should trigger the same business action.

Mailera supports both `message.received` and `message.junk` as distinct event types. As a general consumer rule, do not collapse distinct event types just because they could concern the same message. If a source permits separate occurrences of the same event type for one message, your key must distinguish those occurrences while still recognizing retries of each one.

Avoid timestamps, subject/sender combinations, and raw payload hashes as substitutes for a logical event ID. They encode delivery details or content similarity rather than the event identity your application needs.

Keep key resolution in an application adapter: given an authenticated notification and source configuration, return a namespace and stable event key. This consumer fits into the broader [reliable email-agent workflow](https://mailera.app/en/blog/reliable-email-api-agent-workflows); it does not replace action-level recovery.

## How do you accept an event without a race condition?

Use a durable inbox: a database table that is both the acceptance ledger and the worker’s job source. Do not record an event in one system and then separately enqueue it in another without a transactional handoff.

Mailera lists `X-MailHub-Signature` and `X-Mailera-Signature` as signature header names, and names its timestamp header `X-MailHub-Timestamp`. Keep signature verification and timestamp-based replay checks in the authenticity component, separate from event deduplication.

Follow this receiver sequence:

1. Verify authenticity and apply replay checks before accepting the notification. Follow the signing contract for signature and timestamp handling; see [Mailera webhook authenticity verification](https://mailera.app/en/blog/email-webhook-hmac-verification).
2. Validate the supported event shape and source scope.
3. Resolve the logical event key using the source contract.
4. Attempt an atomic insert into the durable inbox.
5. Commit the transaction. On a conflict, establish that the existing record is durably accepted.
6. Acknowledge acceptance without waiting for slow business work.

![Verified webhook delivery reaches a unique durable inbox record before acknowledgment; workers process the stored record separately.](https://mailera.app/media/85b32bc6722fae3d/email-webhook-deduplication-accept-once-recover-safely-en-1200.jpg)
_The inbox row is both the acceptance record and the worker job._

Here is an application-owned PostgreSQL schema. Every table and column below belongs to your consumer, not the Mailera API. The Python examples use `psycopg`; install it with `pip install "psycopg[binary]"` and set `DATABASE_URL` to your application database connection string.

```python
import os
import psycopg

with psycopg.connect(os.environ["DATABASE_URL"]) as db:
    db.execute("""
        CREATE TABLE webhook_inbox (
            namespace text NOT NULL, event_key text NOT NULL,
            payload jsonb NOT NULL,
            state text NOT NULL DEFAULT 'pending'
                CHECK (state IN ('pending', 'processing', 'completed')),
            accepted_at timestamptz NOT NULL DEFAULT now(),
            lease_until timestamptz, claim_token text,
            PRIMARY KEY (namespace, event_key)
        )
    """)
```

The receiver calls this helper after verification, validation, and key resolution. Pass only the processing data your worker needs in `payload`.

```python
from psycopg.types.json import Jsonb

def accept_event(namespace, event_key, payload):
    with psycopg.connect(os.environ["DATABASE_URL"]) as db:
        inserted = db.execute("""
            INSERT INTO webhook_inbox (namespace, event_key, payload)
            VALUES (%s, %s, %s)
            ON CONFLICT (namespace, event_key) DO NOTHING
            RETURNING event_key
        """, (namespace, event_key, Jsonb(payload))).fetchone()
    return inserted is not None
```

The connection context commits before the function returns. `True` means a new row was accepted; `False` means the uniqueness constraint found an existing one. Neither means the business action is complete. Do not create another job on the conflict path, and do not acknowledge success if the transaction raises an error.

If a duplicate carries conflicting processing data under the same key, flag it for investigation rather than overwriting the accepted job.

### When should the endpoint return success?

Return the sender’s accepted response only after durable acceptance. If persistence fails before commit, return a failure response according to that sender’s contract.

For a concrete provider example, [Microsoft Graph’s webhook delivery documentation](https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks) recommends validating and persisting a notification in a queue, then returning `202 Accepted` within three seconds when processing cannot finish in that window. It specifies a `5xx` response when the notification is neither processed nor queued. Those settings belong to Graph; configure your Mailera-facing endpoint from its own contract.

An HTTP response is not a database transaction. [RFC 9110’s HTTP semantics](https://www.rfc-editor.org/rfc/rfc9110.html) distinguish the observable interface from implementation behavior behind it. Your consumer must connect acknowledgment to persistence explicitly.

## What happens if a worker crashes after acceptance?

Use application-owned `pending`, `processing`, and `completed` states. A `processing` record also needs a recoverable lease, not a permanent lock in application memory.

Claim work inside a short database transaction. Select an eligible pending row, or an expired processing row, with `FOR UPDATE SKIP LOCKED`. While holding its row lock, set `processing`, assign a new claim token and lease deadline, then commit before doing slow work.

This is different from checking whether an event exists and inserting afterward. The uniqueness constraint controls acceptance; the atomic claim controls which worker owns a processing attempt.

Choose the lease duration from your workload. Renew it for long-running work, with updates conditional on the current claim token. A worker that loses ownership must not mark the record completed.

For a business change in the same database, lock the inbox row and validate the current claim in the same transaction that applies the change and marks completion. A rollback then leaves neither change committed. If another worker has reclaimed the row, the old claim must be rejected.

### External actions have another failure boundary

A remote operation can succeed just before the worker crashes. The local row then still says `processing`, and recovery cannot infer whether repeating the call is safe.

![A local business change and completion share one transaction, while an external action can succeed before local completion is recorded.](https://mailera.app/media/e961ac7366eea9d1/email-webhook-deduplication-accept-once-recover-safely-en-1200.jpg)
_A local completion flag cannot close the gap after remote success._

Use a durable business-action key with downstream idempotency where available. Otherwise, reconcile remote state or route an uncertain outcome for review before repeating the action. A lease alone cannot stop a stalled worker’s remote request from completing after another worker takes ownership.

Mailera’s `POST /mailboxes/{id}/send` accepts `Idempotency-Key`, with a 24-hour API idempotency window. Persist the intended send’s key and reuse it for retries within the applicable window; do not generate a new key for each worker attempt. See [prevent duplicate sends with Idempotency-Key](https://mailera.app/en/blog/email-api-idempotency-key).

For uncertain sends, `GET /sends/{id}` exposes `queued`, `sending`, `sent`, `failed`, or `unknown`. Keep the send-attempt reference and follow [send-status recovery before retrying](https://mailera.app/en/blog/email-api-send-status). An `unknown` outcome needs reconciliation, not an assumption of failure.

This design is not a universal exactly-once guarantee. Event acceptance and external side effects have different recovery requirements.

## How long should you retain deduplication records?

Set retention from the source’s retry horizon, your manual replay policy, and your operational recovery window. The record must survive for as long as you intend to recognize a replay as already accepted.

Expiry changes behavior: once the key is deleted, replaying that event can create a new job. Document that boundary and test it. Do not purge pending or unresolved processing records just because their acceptance timestamp is old.

Keep compact dedupe metadata separately from sensitive content retention. You may need the namespace, event key, completion state, and action references longer than an email body. Design payload cleanup so recovery still has the data it needs.

Make deliberate reprocessing an explicit, audited operation. Decide whether it should recalculate local results or also repeat external actions. Deleting a ledger row is not a sufficient reprocessing policy.

## Which mistakes cause duplicate work or lost events?

| Mistake | Failure | Safer design |
| --- | --- | --- |
| Check, then insert | Concurrent requests both decide the event is new | Atomic insert with a uniqueness constraint |
| Process-local dedupe cache | Another instance or a restart has different state | Shared durable ledger |
| Acknowledge before commit | A crash leaves accepted work absent from storage | Commit before acknowledgment |
| Mark completed before acting | Recovery skips unfinished work | Commit completion with the local change, or after external reconciliation |
| Treat an attempt ID as an event ID | Repeated delivery can create another job | Resolve stable logical identity |
| Rely only on worker leases | An old remote call can finish after reassignment | Action-level idempotency or reconciliation |

Run these failure tests against your consumer using synthetic notifications and your own connected test mailbox:

| Test | Expected assertion |
| --- | --- |
| Deliver the same logical event sequentially and concurrently | One accepted inbox row; no extra job |
| Deliver distinct event types for the same message | Distinct events remain distinct |
| Crash before acceptance commits | No success acknowledgment; a repeated request can be accepted |
| Crash after commit but before acknowledgment | A repeated request finds the existing row |
| Crash after claiming work | Lease expiry makes abandoned work recoverable |
| Resume an old worker after reassignment | Its stale claim cannot commit completion |
| Crash after an external action succeeds | Recovery uses the same action key or reconciles before acting |
| Replay after ledger expiry | Behavior matches the documented expiry policy |

Assert business effects as well as row counts. One inbox row does not prove that only one remote call happened.

Log dedupe decisions, claim transitions, and action references. Exclude keys, signing secrets, and complete message bodies. Alert on stuck work and unresolved external outcomes rather than treating every duplicate as an error.

## What changes when an AI agent consumes the event?

Incoming email is untrusted data, not instructions for the worker or agent. Deduplication does not authorize an action or defend against prompt injection. Keep [email prompt-injection defenses](https://mailera.app/en/blog/email-prompt-injection-lab) separate from event processing, and require confirmation before sending.

Separate webhook administration from message reading. Mailera provides `webhooks` and `read` scopes for those needs; do not grant `send` to a read-only consumer. Use [least-privilege mailbox-agent architecture](https://mailera.app/en/blog/least-privilege-email-agents) when separating provisioning credentials from runtime credentials, alongside [Mailera’s security information](https://mailera.app/en/security).

Mailera connects existing mailboxes through its [developer interfaces](https://mailera.app/en/developers); it does not create new mailboxes or email addresses. Use an existing connected test mailbox. Keep this consumer’s PostgreSQL ledger, worker leases, and business-action policy in your application.

**Do this with the API** — Every action in this article is available via the REST API, with curl examples, API keys and webhooks. [View API docs](https://mailera.app/en/docs)
