ai newsMi., 21. Okt.
EilmeldungJudge dismisses Sam Altman’s sister’s sexual abuse lawsuit

Handling AI agent refunds without paying twice: signatures, idempotency and limits in TypeScript

AI assistants are starting to call businesses' systems on behalf of their users: "return the blue jacket", "move my appointment to Thursday". Reading an order is harmless. A refund moves money, and that's where the interesting engineering is. Here are the four failure modes I'd design for first, wi

dev.toDienstag 23:55 · 20.10.4 Min. Lesezeit
Bild: dev.to

Posted on Oct 20 • Originally published at allowfy.com • Fully Autonomous

AI assistants are starting to call businesses' systems on behalf of their users: "return the blue jacket", "move my appointment to Thursday". Reading an order is harmless. A refund moves money, and that's where the interesting engineering is.

Here are the four failure modes I'd design for first, with the TypeScript I use for each. The examples use allowfy, a small open-source package (zero dependencies, Web Request / Response ), but the patterns apply to any webhook-based integration.

Your refund endpoint will be reachable from the internet. Every request must prove it came from your gateway, and be recent.

The scheme: Allowfy-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">. Verify over the raw body, before any JSON parsing, with a constant-time comparison and a time window:

import { verifySignature } from " allowfy "; const raw = await request. text (); // raw, not request.json() const ok = await verifySignature ( process. env. ALLOWFY_WEBHOOK_SECRET!, raw, request. headers. get ( " allowfy-signature " )); if (! ok ) return new Response ( " bad signature ", { status: 401 }); Enter fullscreen mode Exit fullscreen mode The timestamp inside the signed payload is what stops replays: an attacker who captured a valid request can't send it again ten minutes later.

Never let an agent's request go straight to the refund. Split it:

import { createHandler, refuse } from " allowfy "; export const POST = createHandler ({ secret: process. env. ALLOWFY_WEBHOOK_SECRET!, quote: { async " orders.return " ({ order_id }, { customerRef }) { const o = await db. order ( order_id, customerRef ); if (! o ) refuse ( " not_found ", " Order not found ", 404 ); if ( o. status!== " paid " ) refuse ( " not_returnable ", " This order can't be returned " ); return { terms: { order_id: o. id, refund_minor: o. totalMinor, currency: o. currency }, summary: `Return ${ o. item } for a ${ o. totalMinor / 100 } ${ o. currency } refund`, amountMinor: o. totalMinor, // lets amount rules apply currency: o. currency, }; }, }, // execute: below }); Enter fullscreen mode Exit fullscreen mode If the terms change after approval (a price update, a different order), the approval no longer applies and a new one is needed. That rule lives in the gateway, but your quote is what makes the terms explicit.

The classic: your system issues the refund, then the connection drops before the gateway hears back. A naive retry refunds twice.

execute: { async " orders.return " ( terms, { operationId }) { // A unique index on returns.operation_id turns a retry into a no-op. const inserted = await db. returns. insertIfAbsent ({ operation_id: operationId, order_id: terms. order_id, refund_minor: terms. refund_minor }); if ( inserted ) await payments. refund ( terms. order_id, terms. refund_minor, { idempotencyKey: operationId }); return { summary: `Order ${ terms. order_id } returned; refund on its way` }; }, }, Enter fullscreen mode Exit fullscreen mode Note the idempotency key passed on to the payment provider too: the guarantee has to hold all the way down.

refuse() is for deliberate "no" answers (already returned in store): nothing changed, and the customer sees your message. Any other thrown error becomes a 500, which means "unknown, retry".

A per-request limit ("refunds up to $100 need only the customer's approval") is trivial to get around. Limits need a time dimension, per customer, across all agents:

{ "action": "orders.return", "decision": "user_approval", "conditions": { "maxAmountMinor": 10000, "maxTotalMinor": 15000, "maxCount": 3, "windowDays": 7 }, "otherwise": "business_review" } Enter fullscreen mode Exit fullscreen mode Two implementation details matter:

On Supabase (Deno), the whole handler is one call:

import { createHandler } from " npm:allowfy "; Deno. serve ( createHandler ({ secret: Deno. env. get ( " ALLOWFY_WEBHOOK_SECRET " )!, authenticate, read, quote, execute })); Enter fullscreen mode Exit fullscreen mode On Express, mount it before any JSON body parser (the signature covers the raw body):

import { createHandler, toNodeListener } from " allowfy "; app. post ( " /allowfy ", toNodeListener ( createHandler ({ secret, authenticate, read, quote, execute }))); Enter fullscreen mode Exit fullscreen mode Checklist [ ] Signatures verified on the raw body, with a time window [ ] Quote and execute are separate; quote changes nothing [ ] Execute is idempotent per operation ID, all the way to the payment provider [ ] Unknown outcomes are never reported as failed [ ] Limits per customer over time, across agents, serialized [ ] Every step recorded somewhere you can export The gateway side of this (agent identity with signed keys, DPoP-bound tokens, customer sign-in, rules, approvals, a signed ledger, an emergency stop) is what I'm building at Allowfy. The longer, non-code version of this post is on the Allowfy blog. And if you want to see how an AI agent sees your own domain today, Allowfy Check is free and open source.

Questions, or failure modes I missed? I'd like to hear them in the comments.

Quelle: dev.to (dev.to). Original öffnen