Agent payment retries
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 and When the outcome is unknown. This page is the order to do things in.
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.
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
});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.
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);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"])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. |
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.
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}.`;
}
}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. |
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:
const { data: effect } = await ops.actions.reconcileEffect(effectId);
console.log(effect!.outcome, effect!.grade, effect!.verification);effect = ops.actions.reconcile_effect(effect_id).data
print(effect["outcome"], effect["grade"], effect["verification"])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.
await ops.actions.replayEffect(effectId);ops.actions.replay_effect(effect_id)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:
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.',
});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.",
)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:
await ops.actions.invoke(
'refund',
{ paymentIntent: 'pi_3Pabc', amount: 12000, currency: 'usd' },
{ retryOf: invocationId, idempotencyKey: `refund:${orderId}:retry-1` },
);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.
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',
},
});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, with two differences.
- No provider key is sent, so there is no replay. A replay answers
409 REPLAY_REFUSEDwith reasonreplay_unsupportedbefore anything is sent. Step 5 is resolve only. - The read-back is the
verifyrule you declared. Reconciliation repeats thatGETand compares it with the declared status and checks. A match confirms the write. No match leaves itunknown. Write a rule that distinguishes this write from a resource that already existed. An action with noverifyrule, or one whose rule needs the lost response, staysunknownuntil 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, notfailed. Timeout, reset after the write,5xx, and a409from 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.refundandresend.email_send, inside 24 hours. A declared action andgithub.file_updatedo not offer it. - Only Stripe’s own record of a failed refund settles
failedwithout 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 needsactions: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 fromfailed.
Where to go next
Idempotency and retries
The key rules in full, and the per-adapter replay table.
When the outcome is unknown
The four ways an unknown outcome settles, with recorded responses.
Receipts and grades
Reading confirmed, acknowledged, unknown and failed.
Govern any HTTP API
The same flow for a provider without an adapter.

