The mechanics are small. Below is the minimum record and the guard that enforces the table, in pseudocode you can translate to your stack. The claim must be an atomic storage operation, typically a unique constraint or INSERT ... ON CONFLICT, and the reconcile step must query the real external system rather than infer success from an exception.
operation_record = {
operation_key # stable across retries of one intended action
payload_hash # rejects the same key with changed arguments
effect_class # idempotent | compensable | irreversible
status # in_progress | completed | compensated | needs_reconciliation
lease_until # guards concurrent attempts and abandoned workers
result_json # the stored response so duplicates get the original answer
downstream_id # gives reconciliation something concrete to query
undo_ref # required for compensable, e.g. release endpoint and key
}
invoke_tool(operation_key, args):
hash = sha256(canonical_json(args))
claim = claim_operation(operation_key, hash, effect_class)
if claim is payload_conflict: reject "key reused with different arguments"
if claim is completed: return claim.result
if claim is in_progress: return RetryLater
if claim is needs_reconciliation: return escalate_to_human(claim)
switch claim.effect_class:
case idempotent:
return execute_with_backoff(operation_key, args)
case compensable:
run_undo_if_recorded(operation_key) # release, cancel, close
return execute_with_backoff(operation_key, args)
case irreversible:
return escalate_to_human(claim) # no automatic execution on retry
The model should never generate the operation key. The deterministic workflow layer creates it before asking the model to call a tool, then injects it into the tool context. The model supplies business arguments; the runtime supplies identity and attempt metadata, which is how the idempotent tool calls guide frames the contract. A run ID, trace ID, or tool-call ID usually changes when work is retried, so keep those for observability but never use them to decide whether the business effect already happened.
When you receive an event rather than send a call, the same discipline applies to the inbound side. Webhook providers operate on an "at least once" model, so you will get the same event more than once, as the Hookdeck idempotency walkthrough explains. The durable fix is a table with a unique constraint on the event ID, written before any business logic, with a PENDING to DONE status, and a 200 only after the row is committed, as the Asaas webhook idempotency guide documents. If you are building the sender side, the Standard Webhooks specification is the common contract for signatures, timestamps, and retries, and it exists precisely because every provider had been reinventing this.
One more guard belongs at the edge: SSRF, spoofing, and replay protection for any webhook endpoint, which the Standard Webhooks project calls out as core requirements rather than nice-to-haves. A replay of a signed webhook is a duplicate effect with a valid signature, so idempotency and replay protection are two layers of the same defense.