---
title: "Idempotency and retries"
description: "Replay an invocation safely with an Idempotency-Key, and understand why Anlyon will not silently retry a side effect it cannot confirm."
---

Two different things get confused here, so this page separates them:

- **Idempotency** is how you retry safely. You control it, with a key.
- **Automatic retry** is something Anlyon does for delivery and deliberately does *not* do for an action invocation.

## Idempotency keys

Send an `Idempotency-Key` header on a mutation and the key is claimed atomically before the handler runs, so concurrent retries perform the side effect at most once.

```typescript TypeScript
const { data } = await anlyon.actions.invoke(
  'refund-order',
  { charge: 'ch_3P9x', amount: 12000 },
  { idempotencyKey: `refund:${orderId}` },
);
```

```python Python
result = anlyon.actions.invoke(
    "refund-order",
    {"charge": "ch_3P9x", "amount": 12000},
    idempotency_key=f"refund:{order_id}",
)
```

For operations other than action invocations, the cached-response rules are:

| Situation | What happens |
| --- | --- |
| Same key, same request | The original response replays, with an `X-Idempotent-Replay: true` header. |
| Same key, different request | `409` with error code `idempotency_key_reuse`. "Same request" means the same method, path, workspace, environment, credential and JSON body. |
| Duplicate arrives while the first is still in flight | It waits, then replays. If the first does not finish in time, the duplicate gets `409 idempotency_request_in_progress`. |
| The original request failed | The key is freed, so you can retry it. Only successful responses are stored. |
| More than 24 hours later | Stored responses are kept for 24 hours. For most operations the key is then unknown again. **Action invocations are the exception:** see below. |

Choose a key derived from the operation, not from the attempt: `refund:ord_42` is the point, and `refund:${Date.now()}` defeats it.

From `@anlyonhq/sdk` 2.2.0 and `anlyon` 0.5.0 the SDK sends an idempotency key on every invoke and generates one when you do not pass one. A resend is answered from the recorded invocation. Earlier versions send a key only when you pass one, so pass one. A generated key covers one call to `invoke`. When your own code can call `invoke` twice for one business operation, pass your own key.

### Action invocations keep their key

On `POST /api/v2/actions/{ref}/invoke`, the key is also recorded on the invocation itself and kept for as long as the invocation exists. For gated actions, invocation creation, approval admission and approval linkage commit together. A refused admission leaves no invocation or reserved key. The action-specific rules are:

| Situation | What happens |
| --- | --- |
| Same key, same request, any time later | Answered from the invocation **as it is now**, with `X-Idempotent-Replay: current-state`: `200`, or `202` while it still waits for approval. It is not executed, admitted or charged again. |
| The invocation failed, or is `unknown` | The retry shows that outcome. The key is not freed: running the action again is a new decision. Resolve an `unknown` one first, then invoke with a new key and `retryOf`. See [Resolving an unknown outcome](/execution/outcomes#resolving-an-unknown-outcome). |
| The request was refused before an invocation existed (invalid input, action disabled, quota) | Nothing was recorded, so the key is still unused. |
| Same key from another credential, or with a different body | `409 idempotency_key_reuse`, without revealing the invocation. |

This holds for dashboard sessions as well as API keys and OAuth, and when the replay store has lost the key.

**A lost cache is not a licence to re-run**

If a key's short-term record is gone and no invocation holds it, but a durable budget reservation for the same effect still exists, the retry gets `409 BUDGET_EFFECT_ALREADY_RESERVED` rather than executing again. Investigate the original outcome before choosing a new key: a new key can duplicate the work.

Operations that accept an idempotency key are marked in the [API reference](/api-reference). Both SDKs accept one on every operation that supports it, and CI fails if that ever stops being true.

## Provider idempotency on governed actions

The `Idempotency-Key` above is between you and Anlyon. A [governed action](/execution/governed-effects) also has an identity between Anlyon and the provider, and it differs by adapter.

| Action | Key Anlyon sends to the provider | Replay of a lost write |
| --- | --- | --- |
| `stripe.refund` | The effect's `Idempotency-Key`, persisted before the write. The adapter treats the key as usable for 24 hours. | Offered inside 24 hours. After that a repeat could create a second refund. |
| `resend.email_send` | The effect's `Idempotency-Key`. As of 2026-10-03, Resend keeps keys for 24 hours and returns its original response for the same key and payload, sending nothing. | Offered inside 24 hours. After that a repeat could send a second email. |
| `github.file_update` | None. The adapter sends no idempotency key. It sends the reviewed blob SHA as a precondition instead. | Not offered. |
| A declared action | None. A declared action sends no provider idempotency key. | Not offered. A replay is refused before any write. |

A replay of a declared action answers `409 REPLAY_REFUSED` with the reason `replay_unsupported`, and nothing is sent:


```json
{ "success": false, "error": { "code": "REPLAY_REFUSED", "message": "Replay refused (replay_unsupported): ..." } }
```

Reconciliation of a declared action repeats the read-back, never the write.

On a governed action you can also pass an `operationKey`. Repeating the same `operationKey` with the same request returns the existing operation, and nothing is sent again. Reusing it with a different request is refused with `409 OPERATION_KEY_REUSE`. When you pass none, the `Idempotency-Key` is used.

## Why an invocation is not retried for you

When Anlyon dispatches an action and the request times out, or the socket dies after the request was written, the destination **may have processed it**. Anlyon records that as `unknown` rather than `failed`, and stops.

It would be easy to retry. It would also be how one refund becomes two.

So the contract is: Anlyon tells you what it knows, including when what it knows is "I cannot confirm this", and you reconcile with the destination before deciding. An `unknown` invocation carries a message saying so. See [Receipts and grades](/execution/outcomes) for how to read the statuses.

An idempotency key does not make Anlyon send the request again. A retry with the same key and the same request is answered from the recorded invocation, and nothing reaches the destination. To run a `failed` invocation again, invoke with a new key and `retryOf`. An `unknown` invocation must be resolved first. See [Retrying on purpose](/execution/outcomes#retrying-on-purpose).

## Retries that do happen

Event deliveries to your webhook subscriptions retry with backoff, and a delivery that exhausts its retries lands in a dead letter queue. Those retries apply to **delivery**, not to action invocations.

The distinction is not an oversight in one direction or the other. A webhook delivery is safe to retry because the receiver is expected to be idempotent about it. A refund is not, and pretending otherwise would be the most expensive default we could ship.

## How it behaves

- **Idempotency keys are the caller's.** Two different keys are two operations: two refunds of the same payment under two keys are two refunds. An [impact limit](/execution/impact-limits) bounds that.
- **An action invocation is sent once.** When the outcome cannot be confirmed, the invocation is recorded `unknown` and you reconcile before retrying.
- **A declared action sends its write once.** It sends no provider idempotency key. A replay is refused before anything is sent, and reconciliation repeats the read-back.
- **A provider key is usable for 24 hours.** The Stripe and Resend adapters offer a same-key replay inside that window.
- **A GitHub file update is recovered by lookup.** The adapter sends the reviewed blob SHA as a precondition, and reconciliation looks for the commit that carries the effect trailer.
- **A repeated key is answered from the recorded invocation.** For an action with no declaration and no adapter, nothing is sent to the destination again.

## Where to go next

**[Receipts and grades](/execution/outcomes)**

Reading `succeeded`, `failed` and `unknown`, and the grade on a governed action.

**[Actions](/execution/actions)**

Defining and invoking the call.
