Marketing

Idempotency Keys for Agent Triggers: What to Store and Where

Antonio Evans9 min read

A split scene: on the left, two identical webhook envelopes arriving at a server door; on the right, a single ledger row with a key column and a status column, a key passing through a turnstile.
Key points

Key points

  1. 1Assign one idempotency key per logical action before the first attempt, and reuse that key for every retry of that action.
  2. 2Store the key in the same transactional database as the business effect, behind a unique constraint, and write the outcome in the same transaction.
  3. 3Insert the key first; if the insert conflicts, read the existing row and return its stored result instead of running the work again.
  4. 4Keep keys longer than the sender's retry window (24 hours to 7 days is the common range), then delete or archive them.

Why at-least-once delivery makes keys mandatory

Webhook providers do not promise single delivery. Most operate on an "at least once" guarantee, which means the same event will eventually arrive more than once, and your handler has to be built for that (Hookdeck). The sending side makes this concrete. Svix retries a failed webhook delivery immediately, then at 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, and twice more at 10-hour intervals, giving receivers more than a day to recover (Svix). SendGrid, as one example, retries with exponential backoff for up to 24 hours and applies a 10-second response timeout (Hookdeck).

The receiving side has its own duplicate source: you. A handler that times out at 10 seconds, finishes the work at 25 seconds, and then receives the provider's retry will run twice unless it checks. A retry button in a browser client or an agent that calls a tool twice has the same effect. Stripe's own documentation frames the problem clearly: a timeout leaves the original outcome unknown, and an operation that already ran on the server may be repeated by a naive retry (Stripe). Idempotency keys close that hole.

A useful distinction: retries are the sender's job (delivery assurance), keys are your job (operational consistency). You need both (2POINT).

Where the key should live

Put the key in the same database as the side effect, not in Redis, a queue attribute, or process memory. Three reasons:

  1. Atomicity. The key row and the business row must commit or roll back together. If the key is in Redis and the order is in Postgres, a crash between the two writes leaves you either a lost order or a duplicate one, and no retry logic can tell which.
  2. Durability across restarts. An in-memory set dies with the process, and a rolling deploy is a restart.
  3. Queryability during an incident. When someone asks "did we charge twice," you want to run one SQL query, not reconcile two systems.

A cache in front is fine as an optimization. It is not the source of truth.

Providers that publish their own contract land in the same place. Stripe's API v1 saves the first request's status code and response body once endpoint execution begins, and a same-key retry returns that saved response, including a saved 500 error (Stripe). That is a server-side record of outcome keyed by the client's key, exactly the shape recommended here. Stripe also documents that the v1 guidance bounds unresolved network retries to the first day; past that, stop and reconcile instead of retrying blindly (Stripe). Note that the v2 API uses different replay semantics and a 30-day window, so read the contract for the API version you call.

Note where agent calls differ. A webhook arrives with an identifier the provider chose (X-Shopify-Webhook-Id, sg_event_id, or similar), while an agent tool call often arrives with nothing. For agent triggers, derive the key from something stable in the request (conversation ID plus step index, or a hash of the tool name and normalized arguments) and pass it explicitly, for example as an Idempotency-Key header. Never generate a fresh key inside the retry: that turns every attempt into a new action, which is the failure mode Stripe describes as a fresh key repeating the side effect (Stripe).

A schema that works

Two tables: one for the key and its outcome, one for whatever the business effect is. Both live in the same database and are touched in one transaction.

CREATE TABLE idempotency_keys (
    key             text PRIMARY KEY,          -- provider id or derived hash
    scope           text NOT NULL,             -- 'stripe.charge', 'agent.send_email'
    request_hash    text NOT NULL,             -- sha256 of canonical request body
    status          text NOT NULL,             -- 'in_progress' | 'completed' | 'failed'
    response_code   int,
    response_body   jsonb,
    created_at      timestamptz NOT NULL DEFAULT now(),
    updated_at      timestamptz NOT NULL DEFAULT now(),
    expires_at      timestamptz NOT NULL
);

CREATE INDEX idempotency_keys_expires_at_idx ON idempotency_keys (expires_at);

Design choices worth defending:

  • text PRIMARY KEY, not a synthetic integer. The insert then doubles as the duplicate check, with no extra index to maintain.
  • scope in the row. Provider event IDs are only unique within a provider. Without a scope, two systems that both emit evt_123 will collide.
  • request_hash. Same key with different parameters is a client bug, not a retry. Reject it with a 422 so the mismatch surfaces early instead of silently returning a result for different inputs.
  • status. in_progress lets a second delivery detect a concurrent run rather than block indefinitely.
  • response_body. This is the replay payload. Store what the caller needs, not the internal trace.

The first strategy in the Hookdeck guide is exactly this shape: a unique constraint inherited from the event data, side effects performed after the insert, and the unique violation handled as a duplicate rather than an error (Hookdeck).

For the checklists most teams get wrong:

DecisionDo thisWhy
Store locationSame DB as the business writeAtomic commit, one query during incidents
Insert orderInsert key first, side effect second, update row lastConflict becomes the duplicate signal
Concurrent duplicateReturn 409 or poll briefly, do not run againPrevents two workers doing the same job
Same key, different bodyReject with 422Catches client bugs and key reuse
RetentionLonger than the sender's retry windowLate retries must still hit a row
CleanupDelete WHERE expires_at < now() on a scheduleKeeps the table small and the index warm

If you run several agent tools across different model providers and want one place to observe calls, Quanta Inference puts text, image, video, audio and embedding models behind a single OpenAI-compatible API key (Quanta Inference), which means one place to attach and log the key you pass on each call.

The insert-or-read race and how to resolve it

The naive pattern, check then insert, loses under concurrency. Two workers both read "no row," both run the charge. Use the unique constraint as the lock.

def handle(key: str, scope: str, payload: dict, work):
    h = sha256(canonical(payload))
    with db.transaction():
        row = db.execute(
            """INSERT INTO idempotency_keys (key, scope, request_hash, status, expires_at)
               VALUES (%s, %s, %s, 'in_progress', now() + interval '7 days')
               ON CONFLICT (key) DO NOTHING
               RETURNING key""",
            (key, scope, h),
        ).fetchone()

        if row is None:                      # we lost the race
            existing = db.select_key(key)
            if existing.request_hash != h:
                raise Mismatch422()
            if existing.status == 'completed':
                return existing.response_code, existing.response_body
            if existing.status == 'in_progress':
                raise Conflict409()          # let the sender retry later
            # status == 'failed': allow one retry from the same worker
            db.update_status(key, 'in_progress')

        result = work(payload)               # side effect inside the transaction
        db.update_key(key, 'completed', result)
        return result

ON CONFLICT DO NOTHING plus RETURNING tells you in one statement whether you own the key (Hookdeck). The alternative, catching the Postgres unique violation and then reading, works too; just make sure the check and the insert are not separated by application logic. The Hookdeck example handles SQLSTATE 23505, the Postgres code for a unique violation, and returns a success response so the sender stops retrying.

For the released-on-error pattern (delete the key so the next attempt can proceed), remember the risk: if the work succeeded but the transaction rolled back after the external call, deleting the key re-opens the duplicate. Keep the external call and the row update in the same transaction where possible, or record a pending_external state and reconcile.

Retries, timeouts and what the caller gets back

The receiver's response code is part of the contract:

  • 200 with the stored body. Duplicate of a completed action. The sender should stop retrying.
  • 409. The first attempt is still running. The sender should retry later with the same key.
  • 422. Same key, different payload. Not retriable; alert.
  • 5xx. Transient. The key row should be left in failed or deleted so the next attempt can proceed.

On the sending side of your own agent calls, use exponential backoff with jitter, cap the maximum delay, and stop after a fixed number of attempts (Hookdeck). Retriable failures are 5xx, timeouts, connection resets and temporary DNS errors; 4xx responses (except 429) will not fix themselves, and 429 should honor Retry-After. Jitter matters because without it every client that failed at the same moment retries at the same moment, a thundering herd that can knock a recovering service over (Svix).

A worked example with numbers. SendGrid gives you 10 seconds to respond and retries for up to 24 hours (Hookdeck). Suppose your handler needs 18 seconds because an agent step calls a model. Options:

  1. Insert the key, return 202 immediately, and process asynchronously. The fastest correct answer, and the key row already exists when the retry arrives.
  2. Insert the key, do the work, and return 200 after 18 seconds. The provider times out, retries, your second delivery sees in_progress and returns 409; the sender retries later and eventually gets 200 with the stored body. Correct, but noisier.
  3. Raise timeouts nowhere and hope. This is the double-charge path.

Option 1 is the default. If you need option 2 for a synchronous API, at minimum make the key insert the first thing you do, so a slow run never becomes two runs. For agent steps that call several model vendors, putting those calls behind one API removes a variable from your timing budget (Quanta Inference).

Expiry and retention

Retention is a bounded problem: keep keys longer than the longest retry window you can receive, then delete. Published windows give you numbers to work from:

SystemRetry or replay windowMinimum retention
SendGrid Event WebhookUp to 24 hours, 10-second timeout (Hookdeck)48 hours
Svix default scheduleMore than a day, last retries at 10-hour intervals (Svix)48 hours
Stripe API v1 idempotencyGuidance bounds unresolved network retries to the first day (Stripe)48 hours
Stripe API v2 idempotency30-day window (Stripe)31 days

The rule: retention = longest sender retry window x 2, rounded up. If you are unsure, 7 days is cheap for most tables and covers manual replays and incident forensics. If a provider documents a 30-day replay window, use 31 days for that scope, not for everything.

Cleanup is a scheduled DELETE ... WHERE expires_at < now() LIMIT 10000, looped until zero rows, run off-peak. Alternatively, partition by created_at month and drop whole partitions, which avoids the bloat and vacuum cost of row-by-row deletes. If you need the history for audits, archive to object storage before deleting, or move completed rows past expiry into a slower table.

One caution: do not set expiry so short that a legitimately slow sender's late retry misses the row. The failure mode is silent (a second charge), and the only symptom is a very confused customer.

Testing that a duplicate does not double-charge

You cannot test this by reading code. You test it by replaying.

  1. Get the exact body and key. Capture a real delivery in a staging environment, including the provider's event ID or your derived key. SendGrid payloads include sg_event_id and sg_message_id per event (Hookdeck).
  2. Replay it N times, concurrently and sequentially. Assert exactly one row in the business table and exactly one call to each downstream system.
  3. Assert the response. First delivery 200, subsequent deliveries 200 with the same body (or 409 while in progress).
  4. Simulate the timeout. Sleep inside the handler past the provider timeout, replay, assert one charge and one send.
  5. Simulate the crash. Kill the process between the external call and the key update, replay, and confirm the reconciliation path catches it.
  6. Assert no key is deleted on failure without a compensating record. A key deleted after a successful external call is a duplicate waiting to happen.

For counting side effects, wrap the downstream client in a recorder during tests: an email sender that increments a counter, a payment client that returns a fixed ID and increments a counter. Assert the counter is 1. Stripe's guidance points the same way: the operation is identified by its key, and a same-key retry must return the saved response instead of executing the operation again (Stripe).

If your webhook receives batched events, as SendGrid does with a JSON array per POST (Hookdeck), test at the event level: a batch of 5 events where 3 already exist should produce 2 side effects, not 5 and not 0.

Verifying the sender's signature is a separate check and does not make you idempotent. SendGrid signs with ECDSA over the timestamp plus body, using the X-Twilio-Email-Event-Webhook-Signature and X-Twilio-Email-Event-Webhook-Timestamp headers (Twilio), and verifying that signature proves origin, not uniqueness (Stack Overflow). Keep both.

For teams running many agent workflows, the same replay harness applies to tool calls: invoke the tool again with the same key and assert one effect. If you would rather not build every integration yourself, Quanta experts read your context and hand back finished deliverables, including the harness (Quanta Experts).

FAQ

FAQ

Should the idempotency key be generated by the sender or the receiver?

Either can work, but the party best positioned to identify the logical action should generate it. Webhook providers usually send an event ID you can use directly; for agent tool calls, your orchestrator should derive the key and pass it as a header or argument, then reuse it on retry. Never generate a new key inside a retry loop, because Stripe's contract treats a fresh key as permission to repeat the operation (Stripe).

What should I return when a duplicate arrives while the first attempt is still running?

Return 409 (or 202 if you have moved to async processing) rather than blocking or running the work again. The sender should retry later with the same key. If your provider treats any non-2xx as a failure and retries on a schedule, that is fine: the row will eventually move to completed and the retry will get the stored response, matching the replay behavior Stripe documents for v1 idempotency (Stripe).

How long should keys be kept?

Longer than the longest retry window you can receive. Published examples include a 24-hour window for SendGrid retries and a schedule that runs past a day for Svix, while Stripe's v2 idempotency uses 30 days (Hookdeck, Svix, Stripe). Doubling the window and defaulting to 7 days covers almost everything.

Does a signed webhook remove the need for idempotency keys?

No. Signature verification proves the request came from the provider and was not altered, but the provider can still send the same signed request twice, and retries send the same signature. SendGrid's ECDSA scheme over the timestamp and body addresses authenticity, not uniqueness (Twilio). You need both checks.

Can I use Redis instead of my database for the keys?

Only as a cache. Redis cannot participate in the same transaction as your business write, so a crash between the two writes leaves an inconsistent state that no retry logic can resolve. Keep the source of truth in your transactional database, behind the unique constraint, and treat any cache as an optimization that can be rebuilt from it (Hookdeck).

Sources

Sources

  1. How to Implement Webhook Idempotency - Hookdeck
  2. Exponential backoff | Svix Resources
  3. Guide to SendGrid Webhooks Features and Best Practices
  4. Advanced error handling | Stripe Documentation
  5. Webhook Retries and Idempotency Keys - Best Digital Marketing Agency
  6. Idempotent requests | Stripe API Reference
  7. Webhook Retry Best Practices for Sending Webhooks
  8. Getting Started with the Event Webhook Security Features - Twilio
  9. Verifying SendGrid's Signed Event Webhook? - Stack Overflow
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles