- engineering
Idempotency keys are a contract, not a parameter
Why every state-changing action in Odexy requires an idempotency key — and what it costs us if a caller forgets to send one.
You can argue about indentation, naming conventions, REST vs. RPC, or whether to use Tailwind. You can't argue with idempotency keys. They're the difference between a system that survives a retry and one that double-charges your customer.
In Odexy, every state-changing action takes an idempotency key as part of its input. It's not optional. It's not "send one if you want." The handler refuses to run without it.
What an idempotency key does
The contract is simple: send the same key twice within 24 hours and Odexy returns the result of the first call rather than executing the action a second time.
await invoke('email.outbound.send', {
template: { key: 'sales.invoice.send', variables: { ... } },
recipient: { to: ['customer@example.com'] },
idempotencyKey: 'sales.invoice.send.inv_42.v1',
});
Re-run that call after a network blip, after a worker restart, after a deploy that retried the queue — same key, same result, no second email sent.
Why every action requires one
In a typical web app, idempotency keys are added late, retrofitted, and inconsistent. Some endpoints have them, some don't. The team has tribal knowledge about which routes are safe to retry. New engineers don't.
In Odexy, every action's input schema requires an idempotency key:
const Input = z.object({
// ... domain fields
idempotencyKey: z.string().min(8).max(128),
});
The Zod schema enforces it. The handler checks for an existing row with that key + the action name. If found, returns the existing result. If not, runs the action and stores the result keyed by the idempotency key.
Three things this gives us:
1. Retries are safe. The Odexy action client retries on transient failure by default. Without idempotency keys, that's a footgun. With them, it's the right behavior.
2. AI agents get this for free. The Odexy MCP server passes idempotency keys to every action call. If the agent's underlying LLM hiccups and re-calls the same tool, the second call is a no-op.
3. Audit log is clean. A single logical "I sent the invoice" event maps to one audit_log row, even if the network was unreliable and we retried.
What the keys look like
The convention is <action-name>.<entity-id>[.<discriminator>]:
sales.invoice.send.inv_42.v1— sending invoice 42, first version (revisions getv2,v3).recruitment.offer.send.app_123.<offer-rev-hash>— re-sending the offer with the second revision of the comp.email.outbound.send.<message-id>— fallback when there's no obvious entity.
The discriminator matters when the same event class can fire multiple times for the same entity with different intent — like sending an updated offer after a comp revision.
What happens when a caller forgets
This is the design tension. The action's input schema requires the key. If the caller is a Odexy component, they pass it. If the caller is a third-party using our API, they have to send one. What if they don't?
We could:
1. Generate one server-side if the caller doesn't send one. Easy, but it undermines the contract — a forgotten key becomes a stealth bug.
2. Reject the call with a 400. Strict, but it's what the contract says.
3. Make the key optional in the public API but required for retries. Compromise.
We picked option 2. The 400 response is the right teacher. Once you've seen "idempotency key required" once, you remember to send one forever. The error message links to the docs section explaining the why.
What it cost us
A few things:
- Engineer training. Every new hire has to internalize this on their first action. The convention isn't obvious; the why isn't obvious until you've debugged a double-charge bug. We wrote it down.
- Third-party integrations. External developers hit the 400 once. The docs explain it; most send a key by their second call.
- Test boilerplate. Every test passes a fixed key or uses a
crypto.randomUUID(). Slightly more verbose.
Net: worth it. The class of bug we don't have — "the AI agent retried and sent the email twice" — is one we'd be writing post-mortems about every quarter without it.
— The Odexy team
