AWRA OpsHub Search

Two Identical Events Are One Event

Webhook senders retry when they are unsure, so every receiver has to decide what a repeat means. The rule here is short, has one genuinely surprising consequence, and turns on the difference between a request we have seen and a request we have finished.

Integrations & Data AWRA OpsHub Team 13 min read

Any system that sends webhooks will eventually send the same one twice. Not because it is badly built — because it lost the connection while waiting for your answer and has no way to know whether you acted on the message before the wire went quiet. Retrying is the correct behaviour for the sender. Which means deciding what a duplicate means is not an edge case for the receiver; it is part of the contract, and it is the part almost nobody writes down.

The sender can tell you, and usually does not

The clean arrangement is that every event carries a unique reference of the sender's choosing. Two requests with the same reference are the same event; two with different references are different events. It is unambiguous, it is cheap, and there is a widely used header for it.

This endpoint reads that header, under either of the two names it commonly travels as. When it is there, it is used, and the question is settled by the sender who actually knows the answer.

The rest of this post is about what happens when it is not there, which is most of the time, because the systems people want to connect are usually a workflow tool or a script rather than a payment provider with a specification.

With no reference, the message is the reference

The fallback is to derive the reference from the credential and the exact bytes of the body. Same key, same payload, same reference.

That is a genuinely good default, and it has a consequence that surprises people the first time they meet it: two byte-identical messages are one message, however far apart they arrive. There is no window in this rule. A payload sent this morning and sent again unchanged next month resolves to the same reference.

A comparison of what decides sameness when the sender supplies a reference and when it does not.
Two ways to answer the same question. The right-hand column is what you get by default, and its edge is the row at the bottom.

Whether that is what you want depends entirely on the message. For anything carrying its own identity — an order number, a payment reference, a document id — it is exactly right, because a genuine second event about the same thing will differ somewhere. For a message with no distinguishing content, such as a bare heartbeat that says the same sentence every hour, the second one is treated as a repeat of the first.

The fix for that is one line at the sender: include a timestamp, a counter or an identifier in the payload, or send the reference header. A message that is meant to be distinguishable should say something that distinguishes it, which is a reasonable thing to ask of a sender and a much better rule than a receiver guessing at how long two identical messages should be considered different.

Seen is not finished

Now the part that is easy to get subtly wrong, and the reason this section exists.

The obvious implementation of replay protection is: look for the reference; if it is there, this is a duplicate, so stop. That is one condition, it reads as complete, and it is wrong in a way that only shows up on a bad day.

The record is written when the request arrives, before the work is done. So a request that arrived and then did not finish — the process was restarted, the database went away for four seconds, a deployment landed mid-flight — leaves a record behind with no result attached to it.

Under the one-condition rule, the sender's retry then finds that record, concludes the event has already been handled, and returns success. The event never happened. Nothing failed, nothing was logged as an error, and the sender was told everything was fine.

The worst kind of duplicate protection is the kind that also protects you from the one delivery that mattered.

So the check asks two things: has this reference been seen, and did the request that carried it get as far as producing a result. A record with no result is treated as unfinished business, and the retry is allowed through to do the work.

That is the correct direction for this endpoint. A retry that does the work twice is a visible, fixable problem; a retry that silently confirms work nobody did is neither.

What a replay gets back

A confirmed duplicate does not get a fresh response. It gets the stored one — the same status and the same body the original request produced — plus a header saying so.

This matters more than it sounds. If the original call created something and returned its identifier, the retry hands back that same identifier rather than a null or a new one. The sender cannot tell whether it was the first or the fourth attempt except by reading the header, and either way it ends up holding a reference to the thing that actually exists.

The header is there for the people debugging, not for the machines. It answers did my retry do anything without anybody having to reason about it from timestamps.

Two halves, and either one is optional

An accepted event can do two separate things, and a single request may do one, the other, or both.

  1. A message becomes a notification

    Where the payload carries a message, an in-app notification is created — which then fans out to whichever channels the workspace has connected, so an event from an external system can arrive as a chat message or an SMS without knowing anything about either.

  2. An event name triggers workflows

    Where the payload names an event, matching workflows are run with the supplied data. That work is queued rather than done inline, because a workflow can itself call out to other systems and the caller should not be holding a connection open while it does.

  3. Neither one is refused

    The validation requires a message unless an event is named, so a request that would do nothing at all is rejected at the door rather than accepted and quietly discarded.

That last one is the small decision worth copying. An endpoint that accepts a payload it cannot act on is the most confusing possible outcome for whoever is setting it up: the response says success, the logs are clean, and nothing appears anywhere.

Why the answer is accepted rather than done

The response is a status that means accepted, not one that means completed, and the difference is honest rather than pedantic.

The notification half really has been done by the time you get an answer. The workflow half has been handed to a queue, and it has three attempts and a couple of minutes to work in. Reporting completed would be a claim about work that has not started.

The body says which of the two halves happened: the identifier of the notification if one was created, and whether workflows were queued. A sender that reads the body knows exactly what it bought, rather than inferring it from a status code.

What is in place

How a repeat delivery is resolved

A reference from the sender where offered

The standard idempotency header is read under either of the two names it travels as, so a sender that already does this properly is simply believed.

Built in

A reference derived from the payload otherwise

The credential and the exact bytes of the body produce one, so duplicate protection works for senders that supply nothing.

Built in

The pair recorded atomically

The credential and the reference are written as a unique pair in a single operation, so two simultaneous deliveries resolve to one record rather than racing.

Built in

The body digest stored alongside

The hash of the payload is kept with the record, so a reference reused with different content is visible after the fact.

Built in

Seen and finished asked separately

A record with no result is treated as unfinished and the retry proceeds, so an interrupted request is not confirmed as handled.

Built in

The original response replayed verbatim

A confirmed duplicate receives the stored status and body, so any identifier the first call returned comes back rather than a null.

Built in

A header marking the replay

The response says plainly that it is a repeat, which is the answer to the question whoever is debugging actually has.

Built in

The calling address recorded

Each logged request keeps the address it came from, which is what makes an unexpected duplicate traceable to a sender.

Built in

Workflows queued rather than run inline

The caller gets an answer without waiting on the workflow engine or the outbound calls it may make, with three attempts behind it.

Built in

A payload that would do nothing refused

A message is required unless an event is named, so an empty event fails at validation instead of being accepted and dropped.

Built in

A status that means accepted

The response distinguishes work completed from work queued, and the body names which of the two halves ran.

Built in

Every field bounded

Title, message, address, type and event name each carry a length limit, and the address is validated as one before it is stored.

Built in

The two entries in the middle of this list are the ones that took a second attempt to get right. Recording the request before doing the work is what makes duplicate protection possible at all, and it is also what makes an unfinished request look like a finished one unless you ask a second question.

Three positions held on purpose

  • Doing the work twice beats confirming work nobody did. Where the two failure modes conflict, an interrupted request is allowed to be retried, because a duplicate is visible and a silent confirmation is not.
  • A duplicate gets the original answer, not a fresh one. Replaying the stored response means a retry ends up holding the same identifier as the call that succeeded, which is what makes a retry safe for the sender to write.
  • Distinguishing two messages is the sender's job. Deriving the reference from the payload is honest about what it can know, and the alternative — a receiver inventing a window in which identical messages count as different — moves a guess into the part of the system that has the least information.

Five questions about duplicate deliveries

Do you honour an idempotency header?

A good answer sounds like

Yes.

What ours actually is

Read under either of its two common names, and used in preference to anything derived.

What if I do not send one?

A good answer sounds like

One is derived.

What ours actually is

From the credential and the exact bytes of the body, which makes identical payloads one event.

What does a duplicate get back?

A good answer sounds like

The original response.

What ours actually is

The stored status and body, plus a header saying it was a replay.

What if the first attempt crashed halfway?

A good answer sounds like

The retry runs.

What ours actually is

Seen and finished are separate conditions, so a record with no result does not block the retry.

Does a success mean everything ran?

A good answer sounds like

It should say which parts.

What ours actually is

The status means accepted, and the body names the notification created and whether workflows were queued.

Our take

Duplicate protection is one of those features that is easy to add and hard to add correctly, because both of the ways to get it wrong produce a system that looks like it is working. Too little, and the same event is processed four times, which at least announces itself. Too much — a single check on whether the reference has been seen before — and a retry can be answered with a cheerful confirmation of work that was interrupted before it happened, which announces nothing at all and is discovered weeks later by somebody asking where a record went. The two-condition version costs one extra column and one extra clause. That is the whole price of the difference between protection that is safe and protection that occasionally eats the delivery that mattered.

Ask what a repeated delivery does

It is the fastest way to tell whether an endpoint was designed for the internet or for a demonstration, and the answer should include what a half-finished request does.

Talk through inbound events

Frequently asked questions

If my system retries a webhook, will the event happen twice?

Not where the retry carries the same idempotency reference, or the same payload with no reference supplied. The repeat is answered with the response the first call produced, including any identifier it returned, and marked as a replay in a header.

I send the same heartbeat message every hour and only the first one arrived. Why?

With no idempotency header, the reference is derived from the exact bytes of the payload, so byte-identical messages resolve to one event. Adding a timestamp, a counter or an identifier to the body — or sending the header — makes each one distinct.

What happens if your side fails halfway through handling my event?

The request is recorded when it arrives and the result is written when it completes, and the duplicate check requires both. A record with no result is treated as unfinished, so your retry is allowed through and does the work.

Does the response mean the workflows have finished?

The status means accepted. A notification, where the payload carried a message, is created before you get an answer; workflows are queued so that you are not holding a connection open while they run and make calls of their own. The body tells you which of the two happened.

Can one event both notify people and trigger automation?

Yes — a payload carrying both a message and an event name does both. A payload carrying neither is rejected at validation rather than accepted and discarded, so a misconfigured sender finds out immediately.

Help Center

Need a quick answer while you read?

Run inventory, procurement, assets, sales, and field work with approved AWRA guidance for setup, migration, integrations, security, pricing, and support.

Search all approved AWRA public help articles.

Open Help Center