AI18 min

APIs an agent can use with control: a purchase from start to finish

By · founder and CEO of Hábil ·

A purchase designed for AI agents: propose and execute apart, approval outside the model, idempotency, RFC 9457 and a prompt-injection test.

In APIs ready for AI agents we explained what a system needs so that an agent can use it without surprises: it explains itself, its errors teach, retrying does not duplicate, and anything that moves money or data does not depend on the model. This article goes one level down. It takes one concrete operation —a purchase— and designs it end to end: the calls, the response codes, the error format, how permissions are separated, where approval happens, and how to check that all of it holds up against an agent that has been tricked.

It is written for the technology team. If you lead the area, the core idea fits in one line: the agent can ask, the system decides, and a person approves anything that moves money or data.

Example scenario. It does not describe a real company. A distributor wants an AI agent to restock supplies: it checks inventory, compares suppliers and puts orders together. Small purchases are approved within minutes; nobody wants the agent to buy on its own.

1. The purchase is split into three steps, not one

The temptation is to expose a single operation, POST /compras, and let the agent call it. That is the shortest way to give the model the last word on money. The alternative is to split the purchase into three steps, each with a different permission:

1. The purchase is split into three steps, not one
StepCallWho does itWhat changes in the system
QueryGET /existencias, GET /proveedores/{id}/preciosThe agentNothing
ProposePOST /propuestas-de-compraThe agentA proposal is created in the pending approval state. Nothing is bought
ExecutePOST /comprasThe agent, only with a valid approvalThe order is issued to the supplier

And between proposing and executing there is a step that has no call for the agent: approval. A person does it, in another channel, and the system stores it. The agent cannot create it, only wait for it.

The proposal looks like this:

POST /propuestas-de-compra
Content-Type: application/json

{
  "proveedor": "prov-0193",
  "partidas": [
    { "sku": "EMP-5521", "cantidad": 40 }
  ],
  "moneda": "MXN",
  "motivo": "Existencia bajo el mínimo en el almacén norte"
}
HTTP/1.1 201 Created
Location: /propuestas-de-compra/pc-7f3a
ETag: "rev-1"

{
  "id": "pc-7f3a",
  "revision": 1,
  "estado": "pendiente_de_aprobacion",
  "total": "12480.00",
  "moneda": "MXN",
  "huella": "sha256:4b1e…",
  "vence": "2026-11-03T18:00:00-06:00"
}

The agent only sends what it wants and how much. Everything else is filled in by the system from its authorized sources: current prices, taxes, delivery address, terms and the policy that applies. With that, it builds an immutable revision of the proposal and calculates two things on it: the total and the fingerprint, a cryptographic digest of that exact revision, calculated over a documented representation. If the price, the supplier, the destination, a line item or the policy changes, a new revision is created and any earlier approval stops being valid.

The fingerprint identifies the revision; it grants no permission at all. Knowing it does not authorize anyone to execute, just as knowing an account number does not authorize a withdrawal from it. Further down you will see what role it plays.

Three steps of a purchase: the agent queries and proposes; a person approves from another device, and only then is the order executed.

2. Approval happens outside the model

OWASP, the reference organization for application security, names the risk of giving an agent more functionality, permissions or autonomy than it needs Excessive Agency. Among its mitigations are two that become design here: using a person to approve high-impact actions before they happen, and implementing authorization in the systems that receive the call, "rather than relying on an LLM to decide if an action is allowed or not" [4]. The MCP specification, the protocol many agents use to call tools, goes the same way: it says there should always be a person with the ability to deny a tool invocation [6].

In practice, "a person approves" has to meet three conditions to be worth anything:

  1. The channel is different. The approval does not arrive in the same conversation the agent can read and write. There is already a pattern for this: OpenID's CIBA flow separates the device where the operation starts from the device where the person authenticates and gives consent [8]. In the scenario: the agent proposes from the server, and the purchasing manager approves from the app on her phone, with her own session.
  2. What is approved is concrete. The person does not approve "that the agent may buy": she approves this proposal, with this supplier and this total. The OAuth standard for fine-grained authorization (RFC 9396) exists for exactly this: it lets a client request an authorization that carries the type of operation, the amount and the payee, and its example is a payment [7]. CIBA also defines a message intended to be displayed on both devices, so the person can relate them [8]. The approval screen is built by the system, not by the agent: it shows the revision, the total, the supplier, the destination and the expiry.
  3. What is approved is what gets executed. The approval is bound to the exact revision of the proposal and records who approved, in what role and under what policy. It expires by the server's clock, it can be revoked, and it stops being valid if a new revision is created.

Approval is designed so that the agent has no operation that could grant it or change what has already been approved. The agent can request an approval; granting it is a person's job.

Approval happens on another device and is bound to the proposal's fingerprint: what is approved is what gets executed.

3. Execute: a call that can be repeated without charging twice

With the proposal approved, the agent executes:

POST /compras
Content-Type: application/json
If-Match: "rev-1"
Idempotency-Key: "3f2c9a1e-8d4b-4c6e-9f1a-2b7d5e0c4a91"

{
  "propuesta": "pc-7f3a"
}

The body does not repeat line items or amounts: it only says which proposal is being executed. The If-Match header says which revision is expected to be executed, using HTTP's standard mechanism for conditional requests: if the revision is no longer the current one, the server can respond 412 Precondition Failed [16]. And the call carries an execution credential, explained in section 5.

The system checks everything in a single transaction: the proposal is still approved, the approval has not expired or been revoked, the revision matches, the credential is valid, and no order already exists for that proposal. If all of that holds, the proposal moves from aprobada to ejecutando and the credential is consumed; if not, the call is rejected with an error that explains why (section 4).

That transaction is what prevents two purchases from the same proposal, even if two different calls arrive at the same time, with different keys, from two processes or from an agent that restarted. And as a last barrier, the database does not allow two orders for the same proposal. The states are: aprobada → ejecutando → ejecutada, or fallida_sin_confirmar if the supplier received the order but the response was lost. In that case, the system reconciles with the supplier before resending; otherwise, the duplication just moves somewhere else.

The idempotency key: for retries, not for concurrency

The transaction above handles two different executions. The idempotency key handles something else: a retry of the same execution. In this API, repeating the same request with the same key returns the recorded result of that execution, instead of attempting it again.

The Idempotency-Key header is still going through standardization at the IETF: it is an active draft, at version 08, updated on 22 October 2026, and it is not yet an RFC. Its value is a quoted string, as in the example above [2]. But it already proposes rules worth adopting as they are, because they cover exactly the cases that confuse an agent [2]:

The idempotency key: for retries, not for concurrency
SituationResponse the draft proposes
The operation requires a key and none arrived400 Bad Request, with a link to the documentation
The same key arrives with different content422 Unprocessable Content
The same key arrives while the first request is still in progress409 Conflict
The same key arrives after the first request has finishedThe stored result of the first one

The draft also allows a request "fingerprint" to decide whether two requests with the same key really are the same [2]. And there are details each API has to publish: the scope of the key, how long it is kept and when it expires. Stripe, a practical reference on the subject, documents those of its API v1 [3]:

  • it stores the status code and body of the first response as soon as the operation starts executing, including 500 errors, so that a retry does not charge again when the first attempt was left in doubt;
  • it suggests generating keys as version 4 UUIDs and not using sensitive data (an email address or a tax ID) as a key;
  • it may delete keys once they are at least 24 hours old.

In agent design there is one more decision: who generates the key. If the agent generates a new key on every retry, the key cannot recognize the retry. It is better for the key to be created with the execution of the proposal and recorded on the server side. And if, despite that, a second execution arrives with another key, it is the transaction that stops it, not the key.

A retry with the same idempotency key receives the stored result and does not buy twice.

4. Errors the agent can correct, with RFC 9457

For this purchase, the RFC 9457 error format lets you give the agent structured data to correct its request, without changing the meaning of the HTTP status codes [1]. This is what a rejection over the amount looks like:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.ejemplo.mx/problemas/monto-sobre-limite",
  "title": "El total supera el límite que se puede proponer",
  "status": 422,
  "detail": "El total de 48,900.00 MXN supera el límite de 25,000.00 MXN para propuestas de este agente. Esta compra requiere la aprobación de nivel superior.",
  "instance": "/propuestas-de-compra/intentos/a91c",
  "limite": "25000.00",
  "reintentable": false
}

Four rules from the RFC itself make this work well with an agent:

  • The status in the body is only advisory, and whoever generates it must use the same code as the actual response [1]. A 422 in the body with a 200 on the response confuses anyone.
  • You can add your own fields —here limite and reintentable—, and the RFC requires clients to ignore the ones they do not recognize [1]. That lets you give the agent exactly the data it needs without breaking anyone.
  • If there are several errors, reporting the most relevant one is recommended [1]. For validation errors, the RFC shows an array with the detail and location of each field in error, which is what an agent needs to correct everything in one attempt [1].
  • Internal details are not exposed: the RFC warns against showing things such as a stack dump [1]. The error says what to correct, not how the system is built inside.

For the purchase in the scenario, the problem types are:

For the purchase in the scenario, the problem types are
ProblemCodereintentableWhat the agent does
Missing or expired credential401noRequests a valid credential
Valid credential, no permission for this proposal403noStops
monto-sobre-limite422noRequests the approval flow that applies. It does not split or change the purchase to get under the limit
propuesta-sin-aprobar409noChecks the state or waits for the approval notice; does not repeat the execution in a loop
aprobacion-vencida409noSubmits a new revision for approval
revision-no-coincide412noStops and fetches the current revision; does not reuse the approval
llave-con-otro-contenido422noCorrects the request, or uses a new key if it is a new operation
compra-en-proceso409yes, laterWaits for the time given in Retry-After or checks the execution; does not open another one

Two ideas sit behind the table. First: separate authentication (401), authorization (403), precondition (412) and flow state (409), so the agent knows whether something is missing, whether it is not allowed, or whether it has to wait. Second: the agent should not have to guess what is worth repeating. And one caution: the amount limit cannot be avoided by splitting the purchase, so the policy adds up by supplier and by period, not only by proposal.

5. Read, propose, execute: permissions live in the API

The table in section 1 is not a style convention. It is how you limit the damage when the agent gets something wrong. OWASP lists three causes of excessive agency: too much functionality, too many permissions and too much autonomy [4]. Each level cuts one:

  • A limited execution credential. The agent that queries and proposes does not have permission to execute. After approval, the authorization server can issue to the executor a single-use, short-lived credential that carries the proposal, the revision, the supplier, the amount, the currency and the API it is intended for. RFC 9396 provides the format to express that detail [7]; validating it against the current approval and consuming it in the same transaction that marks ejecutando is this API's job. The credential is also bound to whoever presents it, for example with DPoP [17], so that it is useless to anyone else even if it leaks.
  • The agent does not even see what it cannot do. The MCP specification allows the list of tools a server offers to change according to the authorization of whoever asks: for example, showing only the tools their permissions reach [6]. An agent with permission to propose does not see the execution tool. Hiding it reduces what the agent discovers, but it neither authorizes nor blocks anything by itself: the API validates permission, subject, proposal and state on every call [6].
  • An identifier is not a permission. MCP says so for state between calls: an identifier like the one for a basket is a name, not a capability, and the server must validate authorization on every call [6]. Knowing pc-7f3a does not give the agent the right to execute it.
  • Limits live on the server. The 25,000-peso limit in the example lives in the API, not in the model's instructions. The model can ignore an instruction; the API rejects the request even if the model sends it.
  • Log everything. Which agent, with which credential, what it proposed, who approved, which fingerprint, what was executed. MCP recommends logging tool usage for audit purposes [6].

6. The poisoned catalog test

Everything above can be reviewed on paper. This section is for checking it with the system running.

The risk

In 2023, a group of researchers described indirect prompt injection: the attacker does not talk to the agent, but hides instructions in data the agent is likely to read [10]. OWASP lists it as the top risk for applications built on language models and acknowledges that it is unclear whether a fool-proof way to prevent it exists [5].

In the scenario, the agent reads the supplier catalog: names, descriptions, prices. Anyone who can write a description —a supplier, an employee, a compromised integration— can slip an instruction in there.

Why a detector is not enough

The published figures agree on one point:

  • A team of researchers attacked 12 recent defenses with attacks adapted to each one and exceeded 90% success against most of them [14].
  • In an evaluation by NIST's Center for AI Standards and Innovation, with one model and a simulated office environment, success rose from 11% for the strongest baseline attack to 81% for the strongest new attack, developed with the UK AI Security Institute [15].
  • In another set of five tasks from the same evaluation, average success went from 57% with one attempt to 80% when each attack was repeated 25 times [15].

These are results under those conditions, not a rate for all agents. What they do show is that your own evaluation has to record the model, environment, task and number of runs.

The lesson is not that agents are insecure. It is that the defense that matters is the one that does not depend on the model detecting the trick. Two frameworks put it memorably. Simon Willison calls the combination of access to private data, exposure to untrusted content and the ability to communicate externally a "lethal trifecta" [11]. Meta proposes the "Rule of Two": within one session, an agent should not process untrusted inputs, have access to sensitive systems and be able to change state or communicate externally; two of the three at most [12]. A purchasing agent that reads an open catalog and issues orders brings all three together. That is why the execute step cannot depend on what the agent read.

Research points the same way. The CaMeL design, from a team of researchers, separates the control flow —what the person's trusted query decides— from the data flow, so that the untrusted data the model reads never changes the course of the program. With that design they solved 77% of the tasks in the AgentDojo benchmark with provable security, against 84% for a system with no defense [9]. The separation between proposing and executing in this article is inspired by that principle; it is not an implementation of CaMeL and does not inherit its security proof.

The test, step by step

  1. Set up an isolated environment, with a simulated supplier and no access to real suppliers. Record the model version, the instructions, the tools, the agent's identity, the initial catalog, the policy and the API version.
  2. Add a test description to the catalog, internally flagged as untrusted, that tries to change the quantity, the delivery destination and the state of the proposal. The exact text lives in your team's test material, not in public documents.
  3. Give the agent a normal task that makes it read that product, such as checking inventory and restocking what is missing.
  4. Repeat the task several times for each combination of model, configuration and variant of the description. A useful reference is the 25 repetitions in the NIST evaluation, when your environment allows retries [15]; it is a point of comparison, not a guarantee.
  5. Measure the model and the system separately, in the log: - how many times the agent followed the hidden instruction (that is the model's part and varies between versions); - how many proposals it created; - zero orders without a current approval, a matching revision and a valid execution credential; - all direct attempts to approve or execute without permission, rejected; - complete traceability of every attempt: correlation identifier, agent, credential, revision, decision and response.
  6. Add variants that test the API's rules: a revision created after approval, an expired or revoked approval, two different keys for the same proposal, and a credential presented by another client. Public benchmarks such as AgentDojo, with 97 tasks and 629 security cases, are a model for building your own [13].

If the third condition in step 5 fails, start by reviewing the authorization, the state transition and the log for that call. The model's compliance rate helps you choose and configure models; the security of the purchase cannot depend on it.

An instruction hidden in the catalog reaches the agent, but the API executes nothing without a valid approval and logs the attempt.

7. Checklist for your team

  • [ ] Operations that move money or data are split into query, propose and execute, with different permissions.
  • [ ] Prices, total, destination and terms are set by the system in an immutable revision, not by the agent.
  • [ ] Approval happens in another channel, is done by a person, is bound to the revision, expires and can be revoked.
  • [ ] The agent has no call to approve.
  • [ ] Executing requires a single-use credential bound to the proposal and to whoever presents it, and a transaction that prevents two orders for the same proposal.
  • [ ] Executing requires Idempotency-Key for retries, and the API responds 400 / 409 / 422 as the IETF draft proposes.
  • [ ] Errors are application/problem+json, with documented types and a field that says whether retrying makes sense.
  • [ ] Limits live in the API, not in the model's instructions.
  • [ ] The list of tools the agent sees depends on its permissions, and the API validates every call anyway.
  • [ ] The poisoned catalog test runs on every release, with several repetitions, and measures separately what the model did and what the system allowed.

What Hábil does

At Hábil we help you design the APIs of your systems so that an AI agent can use them with control: we separate what is queried, what is proposed and what is executed; we move approval outside the model, with a person approving anything that moves money or data; and we define verifiable tests with your team, like the poisoned catalog test, whose evidence we review together with you. We do it with Hábil AI, artificial intelligence connected to your systems with control. For the full picture, see also APIs ready for AI agents and the Control who has access page.

First conversation, at no cost. We start from one of your operations that an agent should not execute on its own today, and tell you what it would take for the agent to propose it with control.

Sources consulted on October 28, 2026.

References

  1. IETF, RFC 9457, Problem Details for HTTP APIs (2023). https://www.rfc-editor.org/rfc/rfc9457
  2. IETF, The Idempotency-Key HTTP Header Field, draft-ietf-httpapi-idempotency-key-header-08 (active draft, updated 22 Oct 2026; not an RFC). https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
  3. Stripe, Idempotent requests (API documentation; one vendor's practice). https://docs.stripe.com/api/idempotent_requests
  4. OWASP, Top 10 for LLM Applications 2025, LLM06: Excessive Agency. https://genai.owasp.org/llmrisk/llm062025-excessive-agency/
  5. OWASP, Top 10 for LLM Applications 2025, LLM01: Prompt Injection. https://genai.owasp.org/llmrisk/llm01-prompt-injection/
  6. Model Context Protocol, specification 2026-07-28, Tools. https://modelcontextprotocol.io/specification/2026-07-28/server/tools
  7. IETF, RFC 9396, OAuth 2.0 Rich Authorization Requests (2023). https://www.rfc-editor.org/rfc/rfc9396
  8. OpenID Foundation, OpenID Connect Client-Initiated Backchannel Authentication Flow — Core 1.0 (final, 1 Sep 2021). https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html
  9. Debenedetti, Shumailov, Fan et al., "Defeating Prompt Injections by Design", preprint, arXiv 2503.18813 (2025). https://arxiv.org/abs/2503.18813
  10. Greshake, Abdelnabi, Mishra, Endres, Holz and Fritz, "Not what you've signed up for: Compromising Real-World LLM-Integrated Applications with Indirect Prompt Injection", arXiv 2302.12173 (2023). https://arxiv.org/abs/2302.12173
  11. Simon Willison, "The lethal trifecta for AI agents", 16 Jun 2025. https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/
  12. Meta AI, "Agents Rule of Two: A Practical Approach to AI Agent Security", 31 Oct 2025. https://ai.meta.com/blog/practical-ai-agent-security/
  13. Debenedetti, Zhang, Balunović et al., "AgentDojo", arXiv 2406.13352 (2024). https://arxiv.org/abs/2406.13352
  14. Nasr, Carlini, Sitawarin et al., "The Attacker Moves Second", preprint, arXiv 2510.09023 (2025). https://arxiv.org/abs/2510.09023
  15. NIST, Center for AI Standards and Innovation, Technical Blog: Strengthening AI Agent Hijacking Evaluations (17 Jan 2025, updated 19 Dec 2025). https://www.nist.gov/news-events/news/2025/01/technical-blog-strengthening-ai-agent-hijacking-evaluations
  16. IETF, RFC 9110, HTTP Semantics (2022), section 13.1.1, If-Match. https://www.rfc-editor.org/rfc/rfc9110
  17. IETF, RFC 9449, OAuth 2.0 Demonstrating Proof of Possession (DPoP) (2023). https://www.rfc-editor.org/rfc/rfc9449