---
title: "Agent payment retries"
description: "Put a refund behind an action, give every attempt a key derived from the order, tell the model which of three outcomes it got, and settle an unknown outcome without sending the refund twice."
---

An agent asks for a refund. Stripe issues it. The response is lost. The agent retries. This guide takes that sequence step by step and shows what each party does, so the customer is refunded once.

The mechanism is explained on [Idempotency and retries](/execution/idempotency) and [When the outcome is unknown](/execution/when-the-outcome-is-unknown). This page is the order to do things in.

**What this guide does and does not give you**

A governed write is **sent once**. When Anlyon cannot confirm the result, the outcome is `unknown`, the allowance stays held, and nothing is sent again until evidence or a person settles it. That is the guarantee.

It is not exactly-once. The `Idempotency-Key` between you and Anlyon deduplicates your calls to Anlyon, not the provider's view of the world. A same-key replay toward the provider is offered only where the provider deduplicates on a key, which today is `stripe.refund` and `resend.email_send`, each inside 24 hours. See [How it behaves](#how-it-behaves).

## 1. Define the refund as an action

Two keys are in play. An operator key defines the action and stores the credential. An agent key invokes. The model never sees the Stripe key.

```typescript TypeScript
import { Client } from '@anlyonhq/sdk';

const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });

await ops.secrets.put('STRIPE_KEY', {
  value: process.env.STRIPE_SECRET_KEY!,
  allowedHosts: ['api.stripe.com'],
  allowedPlacements: ['header'],
});

await ops.actions.create({
  name: 'refund',
  adapter: {
    type: 'stripe.refund',
    config: { account: 'acct_1Nxyz', mode: 'test', currency: 'usd' },
    credentialSecret: 'STRIPE_KEY',
  },
});

await ops.impactLimits.create({
  name: 'Refunds per day',
  dimension: 'money',
  currency: 'usd',
  period: 'day',
  amount: 50_000, // $500.00, in minor units
});
```

```python Python
import os
from anlyon import Client

ops = Client(api_key=os.environ["ANLYON_OPERATOR_KEY"])

ops.secrets.put(
    "STRIPE_KEY",
    value=os.environ["STRIPE_SECRET_KEY"],
    allowed_hosts=["api.stripe.com"],
    allowed_placements=["header"],
)

ops.actions.create(
    name="refund",
    adapter={
        "type": "stripe.refund",
        "config": {"account": "acct_1Nxyz", "mode": "test", "currency": "usd"},
        "credentialSecret": "STRIPE_KEY",
    },
)

ops.impact_limits.create(
    name="Refunds per day", dimension="money", currency="usd", period="day", amount=50_000
)
```

The adapter sends Stripe an idempotency key per effect and tags every refund with `anlyon_effect` metadata. Both exist so a lost response can be recovered later. The limit reserves the refund amount before dispatch, which is what keeps a held `unknown` from being spent twice.

## 2. Invoke with a key derived from the order

The key identifies the operation, not the attempt. `refund:ord_4417` is right. A timestamp or a random id defeats it.

```typescript TypeScript
const agent = new Client({ apiKey: process.env.ANLYON_AGENT_KEY! });

const { data } = await agent.actions.invoke(
  'refund',
  { paymentIntent: 'pi_3Pabc', amount: 12000, currency: 'usd' },
  { idempotencyKey: `refund:${orderId}` },
);

console.log(data!.status, data!.grade, data!.effectId);
```

```python Python
agent = Client(api_key=os.environ["ANLYON_AGENT_KEY"])

result = agent.actions.invoke(
    "refund",
    {"paymentIntent": "pi_3Pabc", "amount": 12000, "currency": "usd"},
    idempotency_key=f"refund:{order_id}",
)

print(result.data["status"], result.data["grade"], result.data["effectId"])
```

```bash curl
curl -s -X POST https://api.anlyon.com/api/v2/actions/refund/invoke \
  -H "Authorization: Bearer $ANLYON_AGENT_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: refund:ord_4417" \
  -d '{ "input": { "paymentIntent": "pi_3Pabc", "amount": 12000, "currency": "usd" } }'
```

From `@anlyonhq/sdk` 2.2.0 and `anlyon` 0.5.0 the SDK generates a key when you pass none. A generated key covers one call to `invoke`. When your own code can call `invoke` twice for one order, pass your own.

What a repeated key does, any time later:

| Situation | What happens |
| --- | --- |
| Same key, same request | Answered from the invocation as it is now, with `X-Idempotent-Replay: current-state`. Nothing reaches Stripe. |
| Same key, different request | `409 idempotency_key_reuse`. A model that retries with the amount written differently lands here, and nothing is sent. |
| The invocation is `failed` or `unknown` | The retry shows that outcome. The key is not freed. Running again is a new decision, see [step 6](#6-retry-on-purpose). |

## 3. Tell the model which outcome it got

Three outcomes lead to three different next steps. A model told only "error" picks the wrong one. Return the status in words it cannot misread.

```typescript TypeScript
function describe(data: { status: string; grade: string; effectId?: string; error?: string }) {
  switch (data.status) {
    case 'succeeded':
      return `Refund issued. Receipt grade: ${data.grade}.`;
    case 'failed':
      return `Stripe refused the refund. Fix the cause before trying again. ${data.error ?? ''}`;
    case 'unknown':
      return `Refund outcome unknown: Stripe may have applied it. Do not retry. Effect ${data.effectId} is being reconciled.`;
    default:
      return `Refund is ${data.status}.`;
  }
}
```

```python Python
def describe(data: dict) -> str:
    status = data["status"]
    if status == "succeeded":
        return f"Refund issued. Receipt grade: {data['grade']}."
    if status == "failed":
        return f"Stripe refused the refund. Fix the cause before trying again. {data.get('error', '')}"
    if status == "unknown":
        return (
            "Refund outcome unknown: Stripe may have applied it. Do not retry. "
            f"Effect {data.get('effectId')} is being reconciled."
        )
    return f"Refund is {status}."
```

The outcomes, exactly:

| Outcome | When | What the agent should do |
| --- | --- | --- |
| `succeeded` | Stripe answered `2xx`. | Read the receipt. |
| `failed` | The request provably never left, or Stripe answered `4xx`. A `402` is a declined card. | Fix the cause. Retry on purpose, [step 6](#6-retry-on-purpose). |
| `unknown` | Timeout, reset after the request was written, `5xx`, or a `409` from Stripe. | Stop. Keep the `effectId`. A person or the reconciler settles it. |

The invocation's `error` on an `unknown` outcome already says this: `The provider may have applied effect <id>; Anlyon is reconciling it. Do not retry; inspect the effect.` From the CLI, an invoke whose outcome is unknown exits 13.

## 4. Wait, or reconcile now

On `unknown`, Anlyon has already scheduled a read-back. It asks Stripe for the refund by id, or by the `anlyon_effect` metadata. It never writes. A match settles the effect `succeeded` with grade `confirmed`, and the limit moves the amount from reserved to consumed.

You can run the same lookup now, with a key holding `actions:invoke`:

```typescript TypeScript
const { data: effect } = await ops.actions.reconcileEffect(effectId);
console.log(effect!.outcome, effect!.grade, effect!.verification);
```

```python Python
effect = ops.actions.reconcile_effect(effect_id).data
print(effect["outcome"], effect["grade"], effect["verification"])
```

```bash curl
curl -s -X POST https://api.anlyon.com/api/v2/actions/effects/$EFFECT_ID/reconcile \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY"
```

Reconcile works while the environment is halted. An effect that is already settled answers `409`.

A lookup that finds nothing leaves the effect `unknown`. Absence of evidence is not failure: metadata can be removed and a list endpoint can lag. After dispatch, only Stripe's own record of the refund in a failed state settles the effect `failed` without a person.

## 5. Settle it: replay or resolve

Two ways remain. Both need `actions:resolve`, which no key receives by default, and the key that requested the effect can do neither.

**Replay** sends the same refund under the same Stripe idempotency key. Stripe deduplicates, so inside 24 hours a replay either returns the original refund or creates the one that never landed. It is a write, and it passes every control the original passed.

```typescript TypeScript
await ops.actions.replayEffect(effectId);
```

```python Python
ops.actions.replay_effect(effect_id)
```

```bash curl
curl -s -X POST https://api.anlyon.com/api/v2/actions/effects/$EFFECT_ID/replay \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY"
```

After 24 hours the adapter no longer treats the key as usable and a repeat could create a second refund. Then a person checks the Stripe dashboard and records what they found:

```typescript TypeScript
await ops.actions.addEffectNote(effectId, 'Checked Stripe dashboard for pi_3Pabc.');
await ops.actions.resolveEffect(effectId, {
  outcome: 'succeeded',
  reason: 'Refund re_3Pq is visible on pi_3Pabc, created at 14:02 UTC.',
});
```

```python Python
ops.actions.add_effect_note(effect_id, "Checked Stripe dashboard for pi_3Pabc.")
ops.actions.resolve_effect(
    effect_id,
    outcome="succeeded",
    reason="Refund re_3Pq is visible on pi_3Pabc, created at 14:02 UTC.",
)
```

```bash curl
curl -s -X POST https://api.anlyon.com/api/v2/actions/effects/$EFFECT_ID/resolve \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "succeeded", "reason": "Refund re_3Pq is visible on pi_3Pabc, created at 14:02 UTC." }'
```

A manual `succeeded` grades `acknowledged`, never `confirmed`, and keeps `verification: "manual"`. A manual `failed` releases the held amount. The reason must be at least 10 characters and stays on the record with who wrote it and when.

## 6. Retry on purpose

A `failed` invocation can be run again. It is a new decision, so it needs a new key and names the original:

```typescript TypeScript
await ops.actions.invoke(
  'refund',
  { paymentIntent: 'pi_3Pabc', amount: 12000, currency: 'usd' },
  { retryOf: invocationId, idempotencyKey: `refund:${orderId}:retry-1` },
);
```

```python Python
ops.actions.invoke(
    "refund",
    {"paymentIntent": "pi_3Pabc", "amount": 12000, "currency": "usd"},
    retry_of=invocation_id,
    idempotency_key=f"refund:{order_id}:retry-1",
)
```

The original must be `failed`, on its own or resolved as failed, and of the same action. An `unknown` original must be resolved first. A `succeeded` one cannot be retried this way. Each invocation can be retried once. The link shows on both as `retryOf` and `retriedBy`.

## 7. Rehearse the lost response

Stripe cannot be made to lose a response on demand, so the adapter can. On a separate test-mode action, set `faultDrill: "drop_response_after_send"`. Anlyon sends the refund, discards Stripe's answer, records `unknown`, holds the amount, and settles it from Stripe's record on the normal schedule. The first lookup is about 30 seconds later.

```typescript TypeScript
await ops.actions.create({
  name: 'refund-drill',
  adapter: {
    type: 'stripe.refund',
    config: { account: 'acct_1Nxyz', mode: 'test', currency: 'usd', faultDrill: 'drop_response_after_send' },
    credentialSecret: 'STRIPE_KEY',
  },
});
```

```python Python
ops.actions.create(
    name="refund-drill",
    adapter={
        "type": "stripe.refund",
        "config": {
            "account": "acct_1Nxyz",
            "mode": "test",
            "currency": "usd",
            "faultDrill": "drop_response_after_send",
        },
        "credentialSecret": "STRIPE_KEY",
    },
)
```

Every preview of a drilled action says "Fault drill". Keep drills on their own action: every invocation of a drilled action loses its response.

## When the provider is not Stripe

The same steps apply to any API through a [declared action](/guides/any-http-api), with two differences.

- **No provider key is sent, so there is no replay.** A replay answers `409 REPLAY_REFUSED` with reason `replay_unsupported` before anything is sent. [Step 5](#5-settle-it-replay-or-resolve) is resolve only.
- **The read-back is the `verify` rule you declared.** Reconciliation repeats that `GET` and compares it with the declared status and checks. A match confirms the write. No match leaves it `unknown`. Write a rule that distinguishes this write from a resource that already existed. An action with no `verify` rule, or one whose rule needs the lost response, stays `unknown` until a person resolves it.

`github.file_update` sends the reviewed blob SHA as a precondition instead of a key, and offers no replay. Its read-back looks for the commit carrying the effect trailer.

## How it behaves

- **A governed write is sent once.** A dispatch is claimed once, and the database refuses a second dispatch for one effect.
- **A lost response is `unknown`, not `failed`.** Timeout, reset after the write, `5xx`, and a `409` from Stripe all land there.
- **The amount stays held while unknown.** It is neither consumed nor released, and a dependent effect does not start.
- **Reconciliation reads, never writes.** Not finding the refund leaves it `unknown`.
- **Replay is offered for `stripe.refund` and `resend.email_send`, inside 24 hours.** A declared action and `github.file_update` do not offer it.
- **Only Stripe's own record of a failed refund settles `failed` without a person.** Failing to find it or failing to replay it never does.
- **The key that requested an effect cannot reconcile it to `failed`, replay it or resolve it.** Settling needs `actions:resolve`, which no key gets by default.
- **A same-key retry never reaches Stripe.** It is answered from the recorded invocation. Running again needs a new key and `retryOf`, and only from `failed`.

## Where to go next

**[Idempotency and retries](/execution/idempotency)**

The key rules in full, and the per-adapter replay table.

**[When the outcome is unknown](/execution/when-the-outcome-is-unknown)**

The four ways an unknown outcome settles, with recorded responses.

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

Reading `confirmed`, `acknowledged`, `unknown` and `failed`.

**[Govern any HTTP API](/guides/any-http-api)**

The same flow for a provider without an adapter.
