---
title: Idempotency
description: Make a retry safe. Send a key, derive it from the operation, and understand what Veefore guarantees versus what your downstream must.
---

A protected action can time out after the downstream has already applied its
effect. Without an idempotency key, the retry refunds the customer twice.

## Send a key

:::tabs

:::tab SDK
```ts
await agent.act({
  tool: 'refund_customer',
  capability: 'refund.create',
  parameters: { orderId: 'ord_8812' },
  amount: 250_00,
  currency: 'INR',
  idempotencyKey: 'refund-ord_8812',
});
```
:::

:::tab HTTP
```http
POST /gateway/v1/actions
authorization: Bearer aos_at_…
content-type: application/json
idempotency-key: refund-ord_8812

{ "tool": "refund_customer", "parameters": { "orderId": "ord_8812" }, "amount": 25000 }
```
:::

:::

Control plane mutations accept it too, as the `Idempotency-Key` header. Which
endpoints is in the [API reference](/reference/api) — they are marked *accepts
`Idempotency-Key`*.

## Derive it from the operation

The key must identify **the operation**, not the attempt.

| Key | Verdict |
| --- | --- |
| `refund-ord_8812` | Correct. One refund per order |
| `refund-ord_8812-2026-10-03` | Correct, if one refund per order per day is the rule |
| `crypto.randomUUID()` | Wrong. A new key per attempt defeats the whole mechanism |
| `Date.now()` | Wrong. Same reason, less obviously |
| `refund` | Wrong. Collides across every order |

:::danger A fresh UUID per attempt is the same as no key at all
It is the most common mistake here, and it looks correct in review because there
*is* a key. But the retry carries a different key, so it is a different
operation, so the refund happens twice.

Derive the key from the business operation. If you cannot name the operation, you
cannot make it idempotent.
:::

A useful construction: a stable prefix, the resource id, and a window if
repeats are legitimate.

```ts
const idempotencyKey = `refund-${orderId}-${claimId}`;
```

## What a reused key does

| Situation | Result |
| --- | --- |
| Same key, same body | The original result is returned. The action does not run again |
| Same key, **different** body | `409 IDEMPOTENCY_KEY_REUSED` |
| Different key, same body | A new, separate action |

The middle row is the protection that matters. It stops a key from being
accidentally recycled for a different operation, which would return the wrong
result and look like success.

## Across an approval resume

This is where it earns its keep.

```ts
const idempotencyKey = `refund-${orderId}`;

// First attempt → APPROVAL_REQUIRED
try {
  await agent.act({ tool: 'refund_customer', parameters: { orderId }, amount, idempotencyKey });
} catch (error) {
  if (error instanceof VeeforeApprovalRequiredError) {
    await jobs.enqueue('resume', { orderId, approvalId: error.approvalId, idempotencyKey });
  }
}

// Twenty minutes later, after a human approves
await agent.act({
  tool: 'refund_customer',
  parameters: { orderId },
  amount,
  idempotencyKey,          // the same key
  approvalId,
});
```

The key is stable across both. If the resume job itself gets retried — which
queues do — the refund still happens once.

Note that the *approval* has its own binding guarantee: it is tied to the exact
request that created it, and a changed payload is refused with
`APPROVAL_NOT_GRANTED`. The two mechanisms are independent and you want both.

## What Veefore guarantees, and what it does not

**Veefore guarantees** that a repeated key with the same body does not re-run
the pipeline or re-execute the tool. It returns the recorded result.

**Veefore forwards** your key to the downstream as the `idempotency-key` header
on real HTTP egress.

**Veefore cannot guarantee** that your downstream honours it. If your refund
service ignores the header, a retry that reaches it will refund twice regardless
of what Veefore does.

:::warning Make your downstream idempotent too
Veefore's window protects you from re-running the pipeline. It does not protect
you from a downstream that was mid-write when the connection dropped and has no
deduplication of its own.

`EXECUTION_TIMEOUT` is the case to think about: Veefore does not know whether
the effect was applied. If your downstream deduplicates on the forwarded key,
the retry is safe. If it does not, you must check state before retrying.
:::

## When not to retry at all

Even with a key:

- **Never retry a `403`.** The pipeline refused. The answer will not change.
- **Never retry `IDEMPOTENCY_KEY_REUSED`.** Your keys collide. Fix the derivation.
- **Retry `EXECUTION_TIMEOUT` only if the downstream deduplicates.** Otherwise read state first.

See [Errors and retries](/build/errors-and-retries).

## Reads need no key

`Idempotency-Key` is for mutations. A `GET` is already idempotent and the header
is ignored.

## Related

:::cards

:::card Errors and retries | /build/errors-and-retries
When to retry at all.
:::

:::card Handle approvals | /build/handle-approvals
Where a stable key matters most.
:::

:::card Conventions | /reference/conventions
Headers, envelopes, pagination, correlation.
:::

:::card Register a tool | /build/register-a-tool
Egress headers and the forwarded key.
:::

:::
