Idempotency
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
tsawait agent.act({
tool: 'refund_customer',
capability: 'refund.create',
parameters: { orderId: 'ord_8812' },
amount: 250_00,
currency: 'INR',
idempotencyKey: 'refund-ord_8812',
});
httpPOST /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 — 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 |
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.
tsconst 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.
tsconst 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.
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_TIMEOUTonly if the downstream deduplicates. Otherwise read state first.
See Errors and retries.
#Reads need no key
Idempotency-Key is for mutations. A GET is already idempotent and the header
is ignored.