In this article
A reliable email webhook receiver verifies requests, saves events before acknowledging them, and processes them with deduplication and recoverable retries. Receiving an event and completing its business effects are separate steps.
A delivery event arrives. Your endpoint updates a database, triggers an internal notification, and times out before returning a response. The provider retries. Now the same event has triggered the notification twice.
This is a useful starting point for webhook design because every individual component may have behaved reasonably. Your application still produced the wrong result. A dependable receiver must distinguish receiving an event from completing all the work that event causes. It is a separate responsibility from choosing your API or SMTP sending integration.
SendGrid’s webhook explainer calls attention to signatures, duplicate deliveries, and ordering. The architecture below turns those concerns into explicit application responsibilities. It is a suggested design, not a description of any particular provider’s guarantees.
Give the endpoint one durable responsibility
The endpoint should establish that a request is authentic, check basic limits and structure, and persist accepted events before acknowledging them. Business processing belongs in a worker.
“Respond quickly” must not become “respond before saving anything.” If you return success and then lose the process before the event reaches durable storage, the provider may consider the request complete while your application has no record of it.
A simple arrangement is an event inbox table. Insert the event and its pending-processing state in one database transaction. A worker scans or claims pending records. This avoids making an in-memory handoff the only connection between receipt and processing.
Provider-independent pseudocode:
receive(request):
enforce_request_limits(request)
verify_with_provider_rules(request.raw_body, request.headers)
events = validate_and_decode(request.raw_body)
transaction:
for event in events:
insert_event_if_absent(
provider_account,
event.id,
event,
processing_state = "pending"
)
return success
Here, success follows the committed transaction. The verification function represents the selected provider’s documented mechanism; it is not a universal signing algorithm.
Deduplicate events, not messages
One message can legitimately produce several events. Deduplicating solely on a message identifier would discard useful history after the first event.
Use the provider’s event identifier where available, scoped to the relevant provider and account. For example, SendGrid documents a distinct event ID intended for deduplication in its Event Webhook Reference. Check the equivalent contract for your integration.
Enforce uniqueness in storage. A “check then insert” sequence without a uniqueness constraint can race when two copies arrive together. If the provider supplies batches, process every event’s identity independently. A batch boundary is a delivery detail, not necessarily a business boundary.
An existing event should normally receive a successful acknowledgment once its durable presence is confirmed. Its unfinished business work belongs to your worker recovery process, rather than requiring endless redelivery of the original request.
Keep event history separate from current state
Consider a fictional message with three observations: your app submitted it, the provider accepted it, and the receiving server accepted it. The arrival order at your endpoint might differ from the event timestamps.
Do not overwrite the message row with the last webhook type you happened to receive. An older processing event should not turn a recorded delivery back into “processing.”
Keep the observations, then derive the fields your product needs. Delivery outcome, engagement history, and recipient preferences are different concerns. A complaint should trigger the appropriate recipient-handling policy without erasing the fact that the message previously reached a receiving server.
Define those rules per event type and provider contract. A single numeric ranking of all statuses usually becomes confusing once delayed failures or preference changes enter the picture.
Make worker side effects recoverable
Deduplicating receipt does not automatically deduplicate processing. A worker can update a customer record and crash before marking the event complete.
For changes within one database, commit the business update and processing marker together. For an external action, use a durable action record and an idempotent destination where possible. Record the action’s stable identity so a retry can distinguish unfinished work from completed work.
Suppose a bounce event should create a support task. Use an application action key such as the event ID plus create_support_task. If the task service supports idempotency, pass that key through. If it does not, design a reconciliation step and make uncertainty visible; a local flag cannot guarantee exactly-once behavior across a remote request.
Separate temporary failures from events needing investigation
Use bounded retries for temporary worker failures. Store the last error category, attempt count, and next eligible attempt time. Events that repeatedly fail should move to an inspectable failed state, with enough context for an operator to decide whether to replay them.
Handle an unknown event type intentionally. One option is to retain authenticated, structurally valid events as unsupported and acknowledge them, then alert on schema changes. Invalid signatures should never reach business processing. For a malformed batch, document whether the provider permits partial acceptance before designing per-item responses.
Monitor two queues of work: requests that never reach durable storage, and stored events that do not finish processing. A healthy HTTP success rate can conceal a stopped worker.
Test the receiver with a short failure sequence
Create synthetic fixtures that contain no real customer data. Deliver one event twice, deliver two events out of order, stop the worker after its database update, and make the external task service time out after accepting a request. Restart and verify the resulting business state, not just the HTTP responses.
Add a replay operation that uses the same processing code and uniqueness rules as normal traffic. Replaying an event should be an observable operation with a reason, not an undocumented database edit.
A reliable receiver becomes part of your wider product-event sending pipeline. Before connecting production traffic, compare this design with the current event formats, verification steps, and retry behavior in your provider’s documentation. The SendMero docs are the starting point for checking those integration details.