HTTP API
The SDK calls this API for you. Call it yourself when your agent is not written in TypeScript, or when it pays with a direct token transfer rather than x402.
Authentication
Every call carries an API key from Settings, API keys, in the authorization header: Bearer warns_test_.... A test key decides on Base Sepolia and Arc testnet, a live key on Base mainnet.
Ask for a decision
POST https://api.warns.xyz/v1/decisions, with an idempotency-key header of 8 to 64 letters, digits, - or _. The same key with the same body returns the same decision, so retrying after a network error is safe. A new attempt, like the retry of an approved payment, takes a new key.
A direct transfer
For a token transfer your agent sends itself: payroll, supplier invoices.
{ "kind": "transfer", "agentId": "ap-agent", "payer": "0x1111111111111111111111111111111111111111", "network": "eip155:5042002", "token": "0x3600000000000000000000000000000000000000", "to": "0x2222222222222222222222222222222222222222", "amountAtomic": "2000000", "intent": { "payee": "acme-supplies", "invoiceId": "INV-0042", "purpose": "supplier invoice", "expectedMaxUsd": 2 }}
| Field | What it is |
|---|---|
payer | The address the tokens leave from, registered on the agent. For a contract wallet, the contract. |
network | eip155:5042002 (Arc testnet), eip155:84532 (Base Sepolia) or eip155:8453 (Base). |
token | The token contract. USDC only. |
to | Who receives the tokens. |
amountAtomic | The amount in the token's smallest unit. USDC has 6 decimals: 2000000 is 2 USDC. |
intent.payee | Who you pay, as your agent names them. Warns learns the addresses each payee is paid at. |
intent.invoiceId, intent.purpose | Optional. Recorded with the decision. |
intent.expectedMaxUsd | Optional. Above it, Warns raises AMOUNT_OVER_INTENT. |
A transfer gets the same checks and policies as any payment, and two of its own:
- A first payment to an address is held for review (
NEW_DESTINATION), with or without a payee name. The name comes from your agent, so from whatever invoice it read: it never vouches for an address. - A payee you already paid, asking to be paid at a new address, is held (
DESTINATION_CHANGED).
Your policies can match on intent.payee and on protocol (transfer or x402), and a payment schedule holds what falls outside your pay runs.
An x402 payment
The body the SDK sends: agentId, payer, request (method, url, redirectChain), paymentRequired (the raw PAYMENT-REQUIRED header) and requirementIndex, the offer the client picked, plus intent. Prefer the SDK for x402: it also checks what gets signed.
The answer
| Field | What it is |
|---|---|
decisionId | The decision's id, also shown in the dashboard. |
outcome | allow, review or block. |
enforcement | enforced in Protect mode. shadow in Monitor mode: the outcome is what Protect would do, and your agent pays as usual. |
reasons | A list of { code, message }. See the codes. |
score, severity | The risk score from 0 to 100, and low, medium, high or critical. |
reviewId | Set when the payment is held. |
attestation | On an allowed decision in Protect mode: Warns' signature over this exact payment. |
actionFingerprint | What the attestation is bound to. |
expiresAt | Send the payment before this time, one minute after the decision. |
What your agent does with it, in Protect mode:
| Outcome | What to do |
|---|---|
allow | Send the payment, then report it. |
review | Send nothing. Once someone approves it in Reviews, ask again with a new idempotency key: the same payment, for at most the approved amount, goes through once within 15 minutes. |
block | Send nothing. The first reason says why. |
Report the payment
Once the payment is sent, POST https://api.warns.xyz/v1/events/settlement:
{ "decisionId": "4c2f8e0a-...", "success": true, "network": "eip155:5042002", "transaction": "0x...the transaction hash"}
A reported payment teaches Warns the payee's address and counts against your budgets. Warns also watches the chain: a payment out of a registered wallet with no decision behind it freezes the agent.
Commit to a record hash
For a contract that commits onchain to the hash of your own decision record, like a policy wallet's decisionHash. Put the Warns decisionId in the record, hash it the way you always do, then have Warns sign the hash:
POST https://api.warns.xyz/v1/decisions/{decisionId}/commitment
{ "decisionHash": "0x68cd27b475aa3bab..." }{ "decisionId": "4c2f8e0a-...", "decisionHash": "0x68cd27b475aa3bab...", "commitment": "eyJ2Ijoid2FybnMtY29tbWl0bWVudC0xIi..."}
Keep commitment with the record, outside what you hash: a signature inside the record it signs would change the hash. It names the decision and the payment it allowed (payer, recipient, token, amount), on that network, and stays checkable for good.
- Only an allowed decision can be committed to, within 15 minutes of the decision.
- One record per decision, one decision per record. Asking again with the same hash is fine; another hash gets
COMMITMENT_EXISTS, a hash another decision already names getsCOMMITMENT_REUSED. - Warns keeps the hash with the decision. When your contract emits it with the payment, Warns matches the payment to its decision by hash, however long the owner took to sign.
Check the attestation
Optional, for a signer or a contract that wants proof before it pays. Pin the root address published at https://api.warns.xyz/.well-known/warns-keys.json once, never per payment. An attestation is base64url JSON: the root signs a short-lived key (delegation), which signs the decision (EIP-712, domain Warns Decision, version 1, the network's chain id).
import base64, json, timefrom eth_account import Accountfrom eth_account.messages import encode_typed_data WARNS_ROOT = "0x..." # from /.well-known/warns-keys.json, pinned DELEGATION = [("delegate", "address"), ("keyId", "bytes32"), ("notBefore", "uint64"), ("notAfter", "uint64")]ATTESTATION = [("decisionId", "bytes32"), ("tenantId", "bytes32"), ("agentId", "bytes32"), ("payer", "address"), ("payTo", "address"), ("asset", "address"), ("maxAmount", "uint256"), ("actionFingerprint", "bytes32"), ("notAfter", "uint64"), ("keyId", "bytes32")]UINTS = {"notBefore", "notAfter", "maxAmount"} def signer(domain, primary, fields, message, signature): domain_types = [{"name": k, "type": t} for k, t in [("name", "string"), ("version", "string"), ("chainId", "uint256")] if k in domain] typed = {"types": {"EIP712Domain": domain_types, primary: [{"name": n, "type": t} for n, t in fields]}, "domain": domain, "primaryType": primary, "message": {n: int(message[n]) if n in UINTS else message[n] for n, _ in fields}} return Account.recover_message(encode_typed_data(full_message=typed), signature=signature) def verify(token, *, chain_id, payer, pay_to, asset, amount, fingerprint): t = json.loads(base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))) d, m, now = t["delegation"], t["message"], int(time.time()) if signer({"name": "Warns Delegation", "version": "1"}, "Delegation", DELEGATION, d, d["signature"]).lower() != WARNS_ROOT.lower(): raise ValueError("not signed by the Warns root key") if not int(d["notBefore"]) <= now < int(d["notAfter"]) or m["keyId"] != d["keyId"]: raise ValueError("delegated key not valid") if signer({"name": "Warns Decision", "version": "1", "chainId": t["chainId"]}, "DecisionAttestation", ATTESTATION, m, t["signature"]).lower() != d["delegate"].lower(): raise ValueError("not signed by the delegated key") if now >= int(m["notAfter"]): raise ValueError("attestation expired") same = lambda a, b: a.lower() == b.lower() if not (t["chainId"] == chain_id and same(m["payer"], payer) and same(m["payTo"], pay_to) and same(m["asset"], asset) and amount <= int(m["maxAmount"]) and same(m["actionFingerprint"], "0x" + fingerprint.removeprefix("fp1:"))): raise ValueError("attestation is for another payment") return m["decisionId"]
Errors
Every error has the same shape: { "error": { "code", "message", "requestId" } }.
| Status | Code | Why |
|---|---|---|
| 400 | INVALID_REQUEST | A field is missing or malformed. The message names it. |
| 400 | IDEMPOTENCY_KEY_REQUIRED | The idempotency-key header is missing or malformed. |
| 401 | UNAUTHORIZED | The key is wrong, or was revoked. |
| 404 | AGENT_NOT_FOUND | No agent has this agentId in the key's environment. |
| 409 | IDEMPOTENCY_KEY_REUSED | This idempotency key was used with another body. |
| 409 | DECISION_NOT_ALLOWED, DECISION_TOO_OLD | A commitment asked for a decision that is not allowed, or more than 15 minutes old. |
| 409 | COMMITMENT_EXISTS, COMMITMENT_REUSED | The decision already names another record, or the record another decision. |
| 422 | NETWORK_ENVIRONMENT_MISMATCH | A test key on mainnet, or a live key on a testnet. |
| 429 | RATE_LIMITED | See the limits. |