# Overview > What Vestiarion is, and what its API and webhooks give an integration. Vestiarion is an autonomous treasury agent for stablecoin businesses on Arc. It pays invoices in USDC or EURC, and contractor milestones in USDC, through Circle, within guardrails: it never pays a high-risk counterparty, never exceeds a payment limit, and keeps a liquidity buffer. Every decision it makes is recorded, with its reasoning, in an Ed25519-signed, hash-chained ledger. The API gives your integration read access to one workspace: its ledger, invoices, counterparties, milestones, treasury and insights. Webhooks push each new ledger entry to your endpoint as it is appended. - [Try it in 5 minutes](https://www.vestiarion.xyz/docs/guides/try-it): Sample data, the agent deciding, a payment you approve, and the signed ledger, with no wallet or keys. - [Quickstart](https://www.vestiarion.xyz/docs/get-started/quickstart): Create an API key and make your first two requests. - [When the model and the policy disagree](https://www.vestiarion.xyz/docs/research/model-vs-policy): Production decisions beside the written policy's answer to the same facts: where they differed in the first week, and where code refused the model. - [Go live on Arc testnet](https://www.vestiarion.xyz/docs/guides/go-live): Using the app: create the wallets, fund them and take the agent live. - [API reference](https://www.vestiarion.xyz/docs/api): Every v1 endpoint with its parameters, response and errors. - [Webhooks](https://www.vestiarion.xyz/docs/webhooks): Each ledger entry pushed to your HTTPS endpoint, signed. - [AI integration](https://www.vestiarion.xyz/docs/ai-integration): Markdown views, llms.txt and the OpenAPI document for coding agents. - [Changelog](https://www.vestiarion.xyz/docs/changelog): Changes to the API and webhooks, newest first. ## What you can build - **A bot that reports held payments.** List [invoices](https://www.vestiarion.xyz/docs/api/list-invoices) and [milestones](https://www.vestiarion.xyz/docs/api/list-milestones) with `status=held`, and post each one with its `agentReasoning`: the agent's own account of why it did not pay. - **An accounting sync.** Page through [invoices](https://www.vestiarion.xyz/docs/api/list-invoices) with `direction=payable` or `direction=receivable`, and copy each one's status, amount, counterparty, settlement time and `txHash` into your books. - **An audit mirror.** Copy the [ledger](https://www.vestiarion.xyz/docs/api/list-ledger-entries) once, keep it current with [webhooks](https://www.vestiarion.xyz/docs/webhooks), and check each entry's [signature](https://www.vestiarion.xyz/docs/webhooks/verify) yourself. [Verify the ledger](https://www.vestiarion.xyz/docs/api/verify-ledger) to compare your result with Vestiarion's. - **An invoice feed.** [Add invoices from your own system](https://www.vestiarion.xyz/docs/guides/api-invoices), such as an accounting tool or a billing script, and let the agent decide each one. - **A treasury dashboard.** Show the [treasury](https://www.vestiarion.xyz/docs/api/get-treasury)'s account balances, reserve position, obligations due within 7 and 14 days and the latest liquidity forecast. Read [status](https://www.vestiarion.xyz/docs/api/get-status) first, so the dashboard shows whether payments are live or simulated. ## What the API is Every endpoint is under `https://www.vestiarion.xyz/api/v1`. Most read; four add records, with a key given write access: a counterparty, an invoice, a milestone or a payee link. No request moves money, approves a payment, or changes or removes a record: the agent decides what is added as it decides anything typed into the console. Each request carries a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) and reaches only that key's workspace. The [endpoint overview](https://www.vestiarion.xyz/docs/api) lists every endpoint, and [data delivery methods](https://www.vestiarion.xyz/docs/data-delivery) compares pulling with the API to receiving webhooks. --- # Data delivery methods > REST pull or webhook push: how each delivers data, and when to use which. Vestiarion gives you its data in two ways. You pull it with the REST API when you need it, or Vestiarion pushes each new ledger entry to you with a webhook. Most integrations that follow the ledger use both. ## Compared | | REST API (pull) | Webhooks (push) | | --- | --- | --- | | What you get | Any resource: status, the ledger, invoices, counterparties, milestones, the treasury and insights | Each new ledger entry, as a `ledger.appended` event | | Who starts it | You send a `GET` request | Vestiarion sends a `POST` to your HTTPS endpoint | | Latency | Data is read live on each request, so it is as fresh as your last request | Right after the request or the agent tick that appended the entry. A failed delivery is [retried](https://www.vestiarion.xyz/docs/webhooks/retries) with backoff; see [Timing](https://www.vestiarion.xyz/docs/webhooks#timing) | | Authentication | A [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) in `Authorization: Bearer` | You [verify](https://www.vestiarion.xyz/docs/webhooks/verify) the `Vestiarion-Signature` header with the endpoint's secret | | Ordering | The ledger is ascending by `seq`; the other collections are newest first | Not in order: sort by `entry.seq` | | Duplicates | Paging with the cursor never repeats a row, and a stored ledger cursor never goes back before its position | [At least once](https://www.vestiarion.xyz/docs/webhooks/guarantees): de-duplicate on the event id | | Setup | An owner or admin creates a key in Settings | An owner or admin adds an endpoint in Settings, up to 5 per workspace | | Use it when | You need the books, counterparties, the treasury or insights; you backfill history; you cannot receive inbound HTTPS | You want to act on each decision as it is recorded, without polling | ## Using both To follow the ledger without missing an entry: 1. Backfill with [`GET /api/v1/ledger`](https://www.vestiarion.xyz/docs/api/list-ledger-entries), paging with the cursor until `hasMore` is `false`. Store the last non-null `nextCursor`. 2. Receive new entries by [webhook](https://www.vestiarion.xyz/docs/webhooks), and store each one keyed by its `seq`, so a duplicate or an out-of-order delivery lands in the right place. 3. Now and then, resume the ledger from your stored cursor. It returns anything a webhook did not bring, such as an entry whose delivery [failed for good](https://www.vestiarion.xyz/docs/webhooks/retries). ## No WebSocket Vestiarion has no WebSocket or streaming endpoint: webhooks already push each ledger entry as it is appended. --- # Contracts on Arc testnet > The contracts Vestiarion deploys and the Circle contracts it calls, with their addresses on Arc testnet. Vestiarion moves money on Arc testnet through two contracts of its own, which each workspace deploys for itself, and through contracts Circle runs. Every address below opens on Arcscan. ## Vestiarion's contracts Both were written for Vestiarion and are not audited. Their source is in the repository's [`contracts/`](https://github.com/duongnq2798/vestiarion/tree/main/contracts) folder, compiled with Solidity 0.8.37, the optimizer on at 200 runs, for the `paris` EVM version. A workspace that uses one gets its own copy, deployed through Circle's Smart Contract Platform from a deployer wallet of its own. The app shows the copy's address: the escrow's on **Contractors** → **Milestone escrow**, and the spending limit's, with the agent's wallet, on **Treasury**, under the agent's spending limit, **On Arc**. ### VestiarionEscrow Locks a milestone's USDC for a contractor before the work starts. See [Lock a milestone in escrow](https://www.vestiarion.xyz/docs/guides/pay-a-contractor#lock-a-milestone-in-escrow). - Only the workspace's operating wallet, the payer, can call it. - `fund(id, payee, amount, refundAfter)` locks an amount for a payee. `release(id)` pays it to that payee. `refund(id)` returns it to the payer, and only from `refundAfter`. Each hold ends once, released or refunded. - It has no owner and cannot be upgraded. - It emits `Funded`, `Released` and `Refunded`. - An owner or admin deploys it with **Set up escrow**, signed in the ledger as `escrow_deployed`. ### VestiarionSpendingLimit Makes the agent's spending limit binding on Arc. See [Enforce the limit on Arc](https://www.vestiarion.xyz/docs/guides/first-payment#enforce-the-limit-on-arc). - The agent's payments leave the operating wallet through `pay(to, amount, ref)`, and only through it: the agent's own wallet holds no money. The operating wallet approves the contract for what it may draw. - `pay` refuses anything past the daily figure (the current UTC day) or the 7-day figure (that day and the six before it), and pays each `ref` once. - Only its owner, the operating wallet, changes the figures, with `setLimits`. It cannot be upgraded. - It emits `Paid` and `LimitsSet`. `spentToday()` and `spentThisWeek()` read what it paid. - An owner or admin deploys it with **Enforce on Arc**, signed in the ledger as `spending_limit_enforced`. ### Deployed Both run in production in testnet-2, our own test workspace: | Contract | Address | Deployed | Deploy transaction | | --- | --- | --- | --- | | VestiarionEscrow | [`0x74af203fec3f121ff1cd3a763092d1211487702b`](https://explorer.testnet.arc.io/address/0x74af203fec3f121ff1cd3a763092d1211487702b) | Oct 1, 2026 | [`0x550727a6…1860`](https://explorer.testnet.arc.io/tx/0x550727a6a205d4d17f6998c06d5ed5ae84a21296466b2e3c348945884b971860) | | VestiarionSpendingLimit | [`0x9da3c47f73ea9399ac566806a189b0bf47b7d4ba`](https://explorer.testnet.arc.io/address/0x9da3c47f73ea9399ac566806a189b0bf47b7d4ba) | Oct 3, 2026 | [`0xf48ee082…e4ff`](https://explorer.testnet.arc.io/tx/0xf48ee082d2120142e4a6fa3727b64690f255cbc8d91d801403486b3b79b5e4ff) | The workspace's wallets around them: | Wallet | Address | What it does | | --- | --- | --- | | Operating | [`0x97f85033bbd83870a841cf7153f35b387746b6b6`](https://explorer.testnet.arc.io/address/0x97f85033bbd83870a841cf7153f35b387746b6b6) | Pays invoices and buys USYC. The escrow's payer, and the spending limit's owner and the treasury it draws from. | | Reserve | [`0xa8a4ced0cda82b24d11e0386f066eb8c27fd4887`](https://explorer.testnet.arc.io/address/0xa8a4ced0cda82b24d11e0386f066eb8c27fd4887) | Holds the USYC and sells it. | | Agent | [`0xa79bd77b00143ced32a81d1fb8215d7dca21526a`](https://explorer.testnet.arc.io/address/0xa79bd77b00143ced32a81d1fb8215d7dca21526a) | Calls `pay` on the spending-limit contract. It holds no money. | | Escrow deployer | [`0x2590cc3c26f691af6b7203bd283eb055160a1a6c`](https://explorer.testnet.arc.io/address/0x2590cc3c26f691af6b7203bd283eb055160a1a6c) | Deployed the escrow. | | Spending-limit deployer | [`0x10e8f32d53bdcae4b48cb391c0bb84374b7565f3`](https://explorer.testnet.arc.io/address/0x10e8f32d53bdcae4b48cb391c0bb84374b7565f3) | Deployed the spending-limit contract. | ## Circle's contracts Vestiarion calls On Arc testnet: | Contract | Address | What Vestiarion does with it | | --- | --- | --- | | USDC | [`0x3600000000000000000000000000000000000000`](https://explorer.testnet.arc.io/address/0x3600000000000000000000000000000000000000) | Arc's USDC ERC-20 interface, 6 decimals: every payment, escrow lock and approval. | | EURC | [`0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a`](https://explorer.testnet.arc.io/address/0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a) | Pays invoices in EURC. | | USYC | [`0xe9185F0c5F296Ed1797AaE4238D26CCaBEadb86C`](https://explorer.testnet.arc.io/address/0xe9185F0c5F296Ed1797AaE4238D26CCaBEadb86C) | Circle's tokenized money market fund, which the reserve wallet holds. | | USYC Teller | [`0x9fdF14c5B14173D74C08Af27AebFf39240dC105A`](https://explorer.testnet.arc.io/address/0x9fdF14c5B14173D74C08Af27AebFf39240dC105A) | Buys USYC with `deposit`, only in its daily window, and sells it with `redeem`, at any hour. `mintPrice` says whether buying is open. | | USYC Entitlements | [`0xCC205224862C7641930c87679E98999d23C26113`](https://explorer.testnet.arc.io/address/0xCC205224862C7641930c87679E98999d23C26113) | Asked with `canCall` whether each wallet is allowlisted, before the reserve is turned on. | | Gateway Wallet | [`0x0077777d7EBA4688BDeF3E311b846F25870A19B9`](https://explorer.testnet.arc.io/address/0x0077777d7EBA4688BDeF3E311b846F25870A19B9) | Holds the USDC the operating wallet deposits for Gateway payouts and the service budget. | | Gateway Minter | [`0x0022222ABE238Cc2C7Bb1f21003F0a260052475B`](https://explorer.testnet.arc.io/address/0x0022222ABE238Cc2C7Bb1f21003F0a260052475B) | Mints a Gateway payout on the payee's chain, named in each burn intent. | | CCTP TokenMessengerV2 | [`0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA`](https://explorer.testnet.arc.io/address/0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA) | Burns USDC for a payout to another chain. Circle's Forwarding Service submits the mint there. | A payee on another chain is paid in that chain's USDC: | Chain | USDC | | --- | --- | | Base Sepolia | [`0x036CbD53842c5426634e7929541eC2318f3dCF7e`](https://sepolia.basescan.org/address/0x036CbD53842c5426634e7929541eC2318f3dCF7e) | | Arbitrum Sepolia | [`0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d`](https://sepolia.arbiscan.io/address/0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d) | | Ethereum Sepolia | [`0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238`](https://sepolia.etherscan.io/address/0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238) | --- # Try it in 5 minutes > Sign in, load sample data, watch the agent decide, approve a payment and verify the signed ledger. In five minutes, with no wallet and no keys, you can watch Vestiarion's agent decide a small business's payables, make one decision yourself, and check that every decision is signed. Ten minutes more takes the same workspace to real payments on Arc testnet. ## 1. Sign in Open [www.vestiarion.xyz/login](https://www.vestiarion.xyz/login). Choose **Continue with Google**, or enter a **Work email** and choose **Email me a sign-in link**, then open the link it sends. ## 2. Create a workspace Under **Workspace name**, type any name and choose **Create workspace**. The workspace starts as a sandbox: its payments are simulated, and nothing reaches a chain until you take it live. You land on **Treasury**, the console. ## 3. Load sample data The console offers **Try it with sample data**. Choose **Load sample data**. It adds six example counterparties with invoices and milestones, chosen so that one cycle shows every outcome. Within about a minute the agent runs that cycle on its own, without anyone pressing **Run cycle now**. The console then shows what it decided: - a hosting invoice with a purchase order and goods received is paid; - an annual support plan with a 2% early-payment discount is scheduled for its discount deadline; - an overage with no purchase order waits for information; - an invoice over the counterparty's payment limit is held for a person; - a print run billed again under a purchase order that was already paid is flagged; - a verified contractor milestone is released, and an unverified one waits. **Safe to spend today**, at the top of the console, starts from the operating wallet and the USYC reserve, which comes back to the wallet within seconds and which the agent brings back itself before it pays, and takes off what the agent already counts as owed: what is due within 30 days, every open milestone, and a 15% cushion on the next 7 days. Receivables are shown as expected, not counted. **Next 30 days**, further down, shows how that figure is reached, lists each day money is due or expected with the balance after it, and says on which day the wallet would run short. ## 4. Read why Open **AP / AR**. Each decision is a card. **Agent’s reasoning** says why, citing the facts the agent was given: the amount, the purchase order, the risk level, the limit and the balance. Below it are the evidence it checked and its ledger entry. When the model wanted to pay and a guardrail in code stopped it, the card says "Blocked by code, not by the model" and names the rule. ## 5. Decide one yourself Open **Approvals**. The held and flagged payables wait there for a person. On the held one, choose **Approve and pay**, then **Pay now**. In a sandbox the payment is simulated, and the ledger records that you approved it. When the list is empty it reads "Nothing is waiting for a decision." ## 6. Check the ledger Open **Audit log** and choose **Verify hash chain**. Every entry is signed with Ed25519 and linked to the one before it. When every signature and link holds, it answers "Chain intact". To check a workspace's ledger outside the app, see [Verify an audit export](https://www.vestiarion.xyz/docs/guides/audit-export). ## 7. See it on Arc testnet [www.vestiarion.xyz/open](https://www.vestiarion.xyz/open) shows the platform's own numbers, read from the production database, with Arc mainnet and Arc testnet counted apart: **Payments settled**, USDC paid, payees, decisions, and how quickly workspaces reach a first payment. It also lists our own workspaces' latest payments, each linked to the Arc testnet explorer. Among them: - a payment in EURC, weighed against its counterparty's USDC limit at a rate quoted by Circle; - a payout to a vendor on Base Sepolia, listed by its burn on Arc testnet. Circle forwarded the mint to the vendor on Base Sepolia. ## Go further: a real payment on Arc testnet About ten minutes more takes your workspace from simulated payments to real ones on Arc testnet: 1. On the console, choose **Remove sample data**. A workspace with sample data loaded cannot connect Circle: "Remove the sample data first. It exists only to try the agent with simulated payments." 2. Follow [Go live on Arc testnet](https://www.vestiarion.xyz/docs/guides/go-live): create a hosted wallet in one click, get testnet USDC from Circle's faucet for its address, and go live. The console's **Get started** checklist walks you through it. 3. Follow [Your first payment](https://www.vestiarion.xyz/docs/guides/first-payment): add a payee with an address you control, and add a payable due today. The agent decides on it within a minute. Its card links the payment on the Arc testnet explorer. A payee can also be paid in EURC, or on Base, Arbitrum or Ethereum Sepolia. Your first payment explains both. --- # Go live on Arc testnet > Create the workspace's wallets, fund them with testnet USDC and take the agent live. A workspace starts in sandbox. Going live gives it treasury wallets on Arc testnet, funded with testnet USDC from Circle's faucet, and sets the agent to pay from them on its own schedule. This page takes you through it, from **Settings** to the confirmation. Only an **owner** of the workspace can take it live. Everyone else sees the workspace's status in the same place, with the line "An owner can connect Circle and take this workspace live from here." ## Before you start Until the workspace has made its first payment on Arc testnet, the console shows owners and admins a **Get started** checklist: add a wallet, fund it with USDC, go live, add a payee with an Arc address, add a payable, and the first payment. Each step ticks itself off from the workspace's own records, and the next one links to the page where it is done. **Read the guide** opens this page until the workspace is live and funded, and [Your first payment](https://www.vestiarion.xyz/docs/guides/first-payment) after that. The checklist disappears once the workspace is live and its first payment has settled. ![The Get started checklist on the Treasury console, 1 of 6 done: Add a wallet is ticked, and Fund it with USDC is highlighted as the next step, with a Start button. Below it are Go live, Add a payee with an Arc address, Add a payable and First payment on Arc testnet, with Read the guide at the top right.](https://www.vestiarion.xyz/docs/guides/go-live-checklist.png) *The next step is highlighted, and each step ticks itself off.* Open your workspace and go to **Settings**, at `/o//settings`. **Go live** opens the **Workspace** group, and its badge shows where the workspace is. The section shows one step of three at a time: 1. Choose where the wallets live. 2. Create the treasury wallets. 3. Fund the operating wallet, then go live. ## 1. Choose where the wallets live Step 1 depends on the deployment: - **When the deployment has hosted wallets**, step 1 is "Choose where the wallets live". It offers a hosted testnet wallet first (path A), and your own Circle account under "Connect your own Circle account" (path B). - **Otherwise**, step 1 is "Connect your Circle account" and shows only the form for your own account. Follow path B. ![The Go live section of Settings at step 1 of 3, Choose where the wallets live. A box marked Recommended offers a testnet wallet with no Circle account needed, with the button Use a Vestiarion testnet wallet. Below it, a closed row reads Connect your own Circle account. The badge beside Go live reads Sandbox · simulated payments.](https://www.vestiarion.xyz/docs/guides/go-live-choose.png) *Step 1 with hosted wallets: the testnet wallet first, your own Circle account below it.* ### Path A: a Vestiarion testnet wallet You don't need a Circle account for this path. The workspace's wallets are created in Vestiarion's own Circle testnet account, in a wallet set of their own. 1. Under "A testnet wallet, no Circle account needed", choose **Use a Vestiarion testnet wallet**. The line under the button reads "Hosted by Vestiarion · Arc testnet". 2. The page confirms: "This workspace will use a Vestiarion testnet wallet; create its treasury wallets next." Until the wallets are created, you can still change your mind. Open **Connect your own Circle account instead** and follow path B. Once the wallets exist, the choice is fixed, and the page says "To use your own Circle account, start a new workspace." ### Path B: your own Circle account For this path you need two values from your account in the [Circle developer console](https://console.circle.com): - **An API key.** Create a testnet API key in the console, because the workspace's wallets are on Arc testnet. - **An entity secret.** The entity secret is the key that secures developer-controlled wallets. Generate it and **register it in the Circle console first**, as [Circle's guide to registering an entity secret](https://developers.circle.com/wallets/dev-controlled/register-entity-secret) describes, and keep its recovery file safe. Vestiarion does not register it for you. The field asks for "The one registered for developer-controlled wallets." Then, in **Go live**: 1. Open **Connect your own Circle account**, if the step shows the hosted choice first. 2. Paste the key into **API key** and the secret into **Entity secret**. 3. Choose **Connect Circle**. Circle checks the API key before anything is stored. When it accepts the key, the page says "Circle is connected." Both values are then encrypted, and they are never shown again. ![Step 1 with Connect your own Circle account opened: a line pointing to console.circle.com, two empty masked fields labelled API key and Entity secret, the note The one registered for developer-controlled wallets under the second, and a Connect Circle button.](https://www.vestiarion.xyz/docs/guides/go-live-own-account.png) *Path B: paste the API key and the entity secret, then choose Connect Circle.* The entity secret itself is checked in step 2, when the wallets are created. To change either value later, open **Replace Circle credentials**. The new values must belong to the same Circle account, because the workspace's wallets are in it. ## 2. Create the treasury wallets The step is headed "Create treasury wallets". It creates one wallet on Arc testnet for each of the workspace's accounts. With your own Circle account, it also proves the entity secret. ![Step 2 of 3, Create treasury wallets, for a workspace using a Vestiarion testnet wallet. It explains that one wallet is created on Arc testnet for each account, and shows the Create treasury wallets button and a note that counterparties are paid only at a real address. The badge reads Sandbox · hosted testnet wallet.](https://www.vestiarion.xyz/docs/guides/go-live-create-wallets.png) *Step 2, before the wallets exist.* 1. Choose **Create treasury wallets**. 2. The page confirms with "Treasury wallets created: *n*." and lists each account's wallet address, with a button to copy it. If every account already has one, it says "Every account already has a wallet." Under the button, a note reads "Counterparties are paid only at a real address." Each counterparty needs its Arc testnet address before the agent can pay it. [Your first payment](https://www.vestiarion.xyz/docs/guides/first-payment#1-add-a-counterparty-with-an-arc-address) shows where to enter it. ## 3. Fund the operating wallet The step is headed "Fund the operating wallet, then go live". The agent pays from the operating wallet, so this is the wallet to fund. 1. Under **Operating wallet**, copy the address with the copy button beside it. 2. Open [faucet.circle.com](https://faucet.circle.com). As the page says, "select Arc Testnet, and paste this address", then request testnet USDC. 3. Come back to **Go live**. The balance beside "USDC on chain:" is read from the chain when the step opens, and again on each **Refresh**. While it is 0, the page also reads it again when you return to its tab, and every 30 seconds while the tab is open, for 15 minutes, so the USDC shows without a refresh. ![Step 3 of 3, Fund the operating wallet, then go live. It shows the operating wallet's address with a copy button, the line Get testnet USDC at faucet.circle.com: select Arc Testnet, and paste this address, the line USDC on chain: 20.00 USDC with a Refresh button, and the Go live button below a warning that going live cannot be undone from here.](https://www.vestiarion.xyz/docs/guides/go-live-fund.png) *Step 3 after one faucet request: the operating wallet holds 20.00 USDC.* ## 4. Go live 1. Choose **Go live**. The step warns you first: "Going live cannot be undone from here; pausing the agent stops it paying." 2. A dialog asks "Take this workspace live?" and lists what changes: - "Real testnet USDC moves when the agent pays." - "The agent runs every 6 hours on its own." - "The workspace is no longer deleted when inactive." - "To stop it later, pause the agent from the console." 3. Confirm with **Go live**. The page says "This workspace is live." and the badge changes to "Live · paying on Arc testnet", or to "Live · hosted testnet wallet on Arc" on path A. ![The dialog Take this workspace live? over the Go live step. It lists: Real testnet USDC moves when the agent pays. The agent runs every 6 hours on its own. The workspace is no longer deleted when inactive. To stop it later, pause the agent from the console. The buttons are Cancel and Go live.](https://www.vestiarion.xyz/docs/guides/go-live-confirm.png) *The confirmation lists what changes before anything does.* Next, [make your first payment](https://www.vestiarion.xyz/docs/guides/first-payment). ## What changes when you go live - **The agent runs on a schedule.** A live workspace's agent runs a cycle every 6 hours on its own, with no one pressing a button. It also runs one, usually within a minute, after anything that gives it a decision to make, such as a payable added or returned to it. An owner or admin can still run one at any time with **Run cycle now** on the console. The console is **Treasury** in the app's navigation. - **Payments move testnet USDC on Arc** from the operating wallet, within the guardrails. Anything the agent will not pay waits in **Approvals**. - **The workspace is kept**, however long it is inactive. - **Going live is one-way.** Nothing in the app switches a live workspace back to sandbox. ![The Go live section of a live workspace. The badge reads Live · hosted testnet wallet on Arc. Below it are the Operating and Reserve wallet addresses with copy buttons, the line Live since Sep 30, 2026, 09:12 UTC, and Runs every 6 hours; pause the agent from the console to stop it.](https://www.vestiarion.xyz/docs/guides/go-live-live.png) *A live workspace: its wallets, when it went live, and how to stop it.* **To stop the agent**, go to the console and choose **Pause agent**. You can give a reason, which is shown on every page and kept in the ledger. A paused agent runs no cycle and moves no money, and its scheduled runs are skipped. People can still pay or reject from **Approvals**. Owners, admins and approvers can pause the agent. Only owners and admins can start it again, with **Resume agent**. ## Earn on idle cash with USYC Every cycle, the agent decides whether idle USDC should earn in a reserve or come back to the operating wallet before payments fall due. Until you turn the **USYC reserve** on, that reserve is simulated. Turned on, the agent buys real USYC, Circle's tokenized money market fund, on Arc testnet. 1. **Get the wallets allowlisted.** USYC is permissioned: Circle allowlists the wallets that may hold it. **Settings** → **USYC reserve** shows the two addresses: the operating wallet, which buys USYC, and the reserve wallet, which holds and sells it. Ask Circle Support to allowlist both for USYC on Arc testnet. 2. **Turn it on.** An owner or admin chooses **Turn on**. Vestiarion first checks on Arc testnet that both wallets are allowlisted, and names any that is not. It also refuses while the simulated reserve still holds anything, so no simulated balance is ever counted as USYC. The change is signed in the audit log as `usyc_reserve_enabled`. It cannot be turned off from the app. 3. **Let the agent decide.** On its next cycles: - **A sweep.** The operating wallet approves USYC's Teller contract for the USDC, then deposits it, and the USYC goes to the reserve wallet. - **A redemption.** The reserve wallet sells the USYC it needs, and the USDC comes back to the operating wallet. Each move is a real transaction, linked from its decision card on the console. **Payments come first.** Each cycle, before it decides any payment, the agent adds up the USDC payables due today and the verified milestones waiting to be paid, except one whose USDC is locked in escrow. When the operating wallet holds less, it sells the USYC the difference needs first, so those payments are made in that cycle rather than held. The audit log records the move as `cash_brought_back`. A payment still held because the cash was not there is decided again at the first cycle after cash comes in: from the faucet, from a redemption, or from **Bring cash back**. The model that decides sweeps and redemptions is told the same: every payment leaves from the operating wallet, and the reserve's cash counts toward a payment only once it is back there. **To bring cash back yourself**, an owner or admin opens **Settings** → **USYC reserve**, enters an amount in **Amount (USDC)**, or leaves it empty for everything, and chooses **Bring cash back**. The reserve wallet sells that much USYC at its latest price, at any hour, and the USDC comes back to the operating wallet. The audit log records it as `cash_brought_back`, by you. Vestiarion then runs a cycle, so payments waiting for cash are made within a minute, unless the agent is paused. The agent then sweeps nothing back into USYC for 24 hours, so the cash stays in the operating wallet for what you brought it back for. The written policy's reasoning says so, for example "A person brought 152.211756 USDC back from the reserve at 2026-10-05 09:05 UTC, so the agent sweeps nothing until 2026-10-06 09:05 UTC." A sweep the model chooses anyway is not made, and its decision says why. Redemptions for what falls due go on as before. **A person's payment draws on the reserve too.** When **Approve and pay**, or **Pay now** on a held milestone, needs more than the operating wallet holds, it brings the difference back from the reserve first, then pays. You do not need to bring cash back yourself for it. ![The USYC reserve section of Settings, turned on. The badge reads Live. The text reads On since Oct 2, 2026, 14:05 UTC, then The reserve is worth 60.69 USDC at USYC's latest price. Below, the line Leave the amount empty to bring everything back, an Amount (USDC) field, optional, with the placeholder All, and a Bring cash back button beside it.](https://www.vestiarion.xyz/docs/guides/go-live-usyc.png) *With the reserve on, an owner or admin can bring cash back to the operating wallet at any hour.* What to expect: - **When USYC can be bought.** Only between its daily price update, around 08:30 New York time, and 14:00 New York time, on business days. Outside that window the agent is told it cannot sweep. A sweep it decides anyway is not made, and the decision says why. Redemptions are possible at any time. - **What the reserve is worth.** Every cycle reads it from the chain: the reserve wallet's USYC at the latest price. The reserve no longer shows "simulated", and its yield is the fund's own over the last days, which is what the agent weighs a sweep against. - **When a sweep pays.** The agent sweeps only when the yield beats what the two transfers cost. It counts the yield over how long the swept cash would stay, up to 30 days: what falls due is paid from the operating wallet first, and only what it cannot cover comes back from the reserve, on its day. - **Bounded moves.** Code sets how much the agent may move. A sweep never takes the operating wallet below its buffer for what falls due within 7 days. A redemption brings back at most what falls due within 14 days needs, with a 15% cushion, and at least what the written policy redeems. The model is told these bounds. A move outside them is brought within them, and its audit log entry keeps what the model chose, as `boundedByCode`. - **The contracts.** USYC, its Teller and the allowlist's Entitlements contract, with their addresses, are on [Contracts on Arc testnet](https://www.vestiarion.xyz/docs/contracts). - **Fees.** Buying and selling cost nothing for these wallets on Arc testnet, and Circle's Gas Station pays the network fee. The price rises day by day as the fund earns, about 3.4% a year at the end of September 2026. ## When something fails A failed step shows one of these messages under its button. You can repeat any step. Wallets created before a failure are kept, and creating the wallets again adds only the missing ones. | Message | What to do | | --- | --- | | Paste both the API key and the entity secret. | Both fields are required. Paste each value in full. | | Circle did not accept this API key. | Check that you copied the whole key, that it is still active in the Circle console, and that it is a testnet key. | | This Circle API key is for Arc mainnet (LIVE_API_KEY). This workspace is on Arc testnet: paste a test key (TEST_API_KEY). | Vestiarion runs on Arc testnet. In the Circle console, copy an API key from the testnet side: it starts with `TEST_API_KEY`. | | Could not reach Circle; try again. | Circle did not answer in time. Try the step again in a moment. | | This workspace's wallets belong to the connected Circle account; use its credentials. | The credentials belong to a different Circle account from the one that holds the wallets. Use that account's API key and entity secret. | | Connect Circle first. | Step 1 is not done. Choose the hosted wallet, or connect your Circle account. | | Circle did not accept the entity secret; reconnect with the right one. | The entity secret is not the one registered to the API key's account. Open **Replace Circle credentials**, paste the registered secret, then choose **Create treasury wallets** again. | | Create the treasury wallets first. | Complete step 2, then go live. | | This workspace is already live. | It went live already, from another tab or by another owner. Reload the page. | | The stored Circle credentials cannot be read; reconnect. | Paste the API key and entity secret again under **Replace Circle credentials**. | | The Circle credentials changed while going live; try again. | Someone replaced the credentials while this step was running. Reload the page and repeat the step. | | This workspace has no operating wallet; it cannot take new credentials. | This appears when replacing the credentials of a live workspace. The new ones cannot be proven without an operating wallet, so the stored credentials stay. | | Hosted testnet wallets are not available on this deployment. | Connect your own Circle account (path B). A workspace that already uses a hosted wallet pays nothing until hosted wallets are available again. | | A workspace with its own Circle account or wallets cannot switch to a hosted wallet. | This workspace already has Circle credentials or wallets. Continue with your own account. | | All hosted testnet wallets are taken; connect your own Circle account instead. | Follow path B with your own Circle account. | | This workspace's wallets are hosted by Vestiarion; start a new workspace to use your own Circle account. | The hosted wallets exist, so the choice is fixed. Create a new workspace to use your own account. | | Could not read the balance from Circle; try again. | The balance could not be read. Choose **Refresh** again. | | Remove the sample data first. It exists only to try the agent with simulated payments. | Open the console and choose **Remove sample data**, then connect Circle or choose a wallet host. | | Something went wrong; try again. | An unexpected error. Try the step again. | --- # Your first payment > Add a counterparty and an invoice, run a cycle, and follow the payment to the explorer and the ledger. This page takes a workspace from an empty book to its first payment on Arc testnet: add a counterparty and an invoice, let the agent decide, and follow the payment to the explorer and the signed ledger. Start with a workspace that is live and has testnet USDC in its operating wallet. [Go live on Arc testnet](https://www.vestiarion.xyz/docs/guides/go-live) sets that up. Until the first payment settles, the console's **Get started** checklist follows these steps too, and links the next one. Who does what: - **Owners and admins** add counterparties and invoices, and run cycles. - **Owners, admins and approvers** decide the payments the agent holds. - **Viewers** see everything, but have no controls. ## Try it with sample data first To see the agent at work before you set anything up, open the console of a new workspace. While the workspace has no counterparties and has not connected Circle, the console shows **Try it with sample data**. Choose **Load sample data**: it adds six example counterparties, marked **Sample** on the Counterparties page, with invoices and milestones chosen so that one cycle shows every outcome. The agent usually runs that cycle within a minute on its own; then read the decisions on the console and on **Approvals**: - a hosting invoice with a purchase order and goods received is paid; - an annual support plan with a 2% early-payment discount is scheduled for its discount deadline; - an overage with no purchase order waits for information; - an invoice over the counterparty's payment limit is held for a person; - a print run billed again under a purchase order that was already paid is flagged; - a verified contractor milestone is released, and an unverified one waits. Sample payments are simulated. While **Sample data is loaded**, the console offers **Remove sample data**: it removes the sample counterparties and everything recorded against them, and the ledger keeps its entries. Remove the sample before you connect Circle; until then, connecting answers "Remove the sample data first. It exists only to try the agent with simulated payments." ## 1. Add a counterparty with an Arc address A counterparty is a business or person the workspace pays or invoices. Open **Counterparties**, open **Add counterparty**, and fill in the form: - **Legal or trading name**. - **Role**: **Vendor** or **Contractor** for someone you pay. - **Payment limit (USDC)**: the most the agent may pay this counterparty in one payment. Screening can lower it: the card shows the limit you set and the current one. - **Chain**: **Arc testnet** for a payee paid on Arc. For a payee who wants USDC on another chain, choose **Base Sepolia**, **Arbitrum Sepolia** or **Ethereum Sepolia**: see [Pay a payee on another chain](#pay-a-payee-on-another-chain). - **Payment address**: the counterparty's address on the chain you chose, `0x` followed by 40 hexadecimal characters. An address that mixes capital and small letters must match its checksum, or the form says "This address's capital letters do not match its checksum, so a character is likely wrong." A payment to a mistyped address reaches no one, so copy it again from where it came. An address in one case alone is taken as written. - **Jurisdiction**: an ISO code or a country name. - **Billing email**, optional: a payee is told there each time it is paid (see [Tell the payee it was paid](#tell-the-payee-it-was-paid)), and a client gets the reminders you turn on (see [Let the agent remind the client](#let-the-agent-remind-the-client)). ![Add counterparty opened on the Counterparties page, its form filled in: Legal or trading name Northstar Studio, Role Vendor, Payment limit (USDC) 50.00, Chain Arc testnet, a Payment address starting 0x, Jurisdiction US, and Billing email accounts@northstar.example. The Add and screen button is at the bottom right.](https://www.vestiarion.xyz/docs/guides/first-payment-counterparty.png) *A vendor with a payment limit and an Arc testnet address, ready to add and screen.* Then choose **Add and screen**. The counterparty is screened as soon as it is saved, and a message gives the result: "*Name* added and screened: *level* risk." The agent never pays a counterparty screened high risk. A live workspace screens against OpenSanctions; a sandbox, whose payments are simulated, screens against a short list built into the app. If screening cannot run, for example because the screening service does not answer, the counterparty is still saved, and the message says it "was added, but screening is incomplete". Its row then reads **Not screened yet**, and the agent pays it nothing: a payable to it is held with the rule `counterparty.unscreened`, and so is a contractor's milestone. Screening runs again at every cycle. Once it gives a verdict, the agent decides what it held again, within a minute. A person can still pay it from **Approvals**. Under **Counterparty book**, each counterparty is one row: its role and chain, its risk, what it may be paid now (**Allowed now**), its address, and what it needs before the agent can pay it. The rows that need someone come first: **Review match**, **Confirm address**, **Address needed** and **Not screened yet**, then **Ready to pay**. Open a row for its screening, limits, history and address. If screening matched someone else with the same name, the counterparty's row reads **Review match**; open it to see the match under **Screening match**, with what that does to its limit. A member who can approve payments can choose **Not this person**, say why, and confirm with **Dismiss the match**. The counterparty is screened again at once without that match (a match with anyone else still counts), the ledger records `screening_match_dismissed` with the reason, and payments held on the old risk are decided again within a minute. A name can match several people: the box then says how many others it matched, and **Not this person** lists every one, with its score, so one review dismisses them all under one reason (each is recorded, and a match not listed still counts). A match recorded before October 2, 2026 reads "This match was recorded before Vestiarion kept every possible match." Choose **Screen again** first, and **Not this person** appears. > **Without an address, a payment is held** The form marks **Payment address** "Optional until payment setup", but the agent can pay only a counterparty that has one. As the Go live page puts it, "A payment to a counterparty without one is held for review." You can add it later with **Edit address** on the counterparty's card. ### Changing a payment limit Owners and admins open the counterparty's row and change its limit with **Edit limit**, under **Configured limit**. Enter the new **Payment limit (USDC)** and choose **Save limit**. **Allowed now** follows at once: the whole limit for a counterparty screened clear, a quarter of it for medium risk, and 0 for high risk. A vendor or contractor always has a limit, because without one the agent could pay any amount. The ledger records who changed the limit, and from what to what. ### Pay a counterparty without purchase orders The agent pays an invoice on its own only when its three-way match is complete: the goods or services received and a purchase order on file. Some suppliers never issue one, such as a utility or a subscription. A vendor's or contractor's row on **Counterparties** shows **Purchase orders**: "Needed before the agent pays", as for every counterparty at first, or "Not needed · goods received still is". Owners and admins choose **Change** beside it, then **Pay without purchase orders** or **Require purchase orders**. - **Paid without purchase orders.** The agent pays the counterparty's invoices with no purchase order on file. The goods or services still have to be marked received, and every other check stays. Its invoices that waited only for a purchase order are decided again within a minute. - **Needing them again.** The agent asks for a purchase order before it pays or schedules any of its invoices. A payment already scheduled without one waits for the details on its day. - **Enforced in code.** The model is told whether the counterparty needs purchase orders, and the written policy asks for information on the same condition. If the model chooses to pay or schedule an invoice whose match is incomplete anyway, code refuses it, and the invoice waits for the details as if the agent had asked. - **Recorded.** The ledger records who changed it, as `counterparty_purchase_orders_changed`. ### Ask a payee for their address You don't have to collect a payee's address yourself. In the counterparty's row, owners and admins choose **Ask for address**, then **Create link**: - The link is shown once, with **Copy link**. Send it to the payee however you usually reach them. - The payee opens it without an account. They see which business wants to pay them and enter their own Arc address. - The link works once and expires after 7 days. A new link replaces the payee's unused one, and **Revoke** in the row stops it. - An address that comes in through a link counts as a change, like any other: the row says "not yet confirmed", and the agent holds payments to that payee until someone here confirms it, as below. ### Changing an address Owners and admins change a counterparty's address with **Edit address** in its row: enter the new **Arc address**, or leave it empty to clear it, and choose **Save address**. Changing where a counterparty is paid is how payments get redirected to the wrong wallet. So after a change, the row reads **Confirm address** and says "not yet confirmed", and the agent holds every payment to that counterparty, whatever the amount, until a person confirms the new address. There are two ways to confirm it: - **Approve and pay** on a held payment to it. The approval card shows where the money goes, after "Pays to". If the address changed after the page loaded, the approval is refused with "This counterparty's address changed after this page loaded. Check the new address and try again." - **Confirm address** in the counterparty's row. Owners, admins and approvers see it. A payment the agent held for the change goes back to it, and it decides that payment again, with every check, in the cycle the confirmation starts. ![Northstar Studio's row in the Counterparty book: Vendor on Arc testnet, clear risk, Allowed now 50.00 USDC, and a badge reading Confirm address. The row is open, showing its configured limit, what it may be paid now, its jurisdiction and when it was last screened, then the new Arc address, an Edit address button, a badge reading Changed 2026-09-30, not yet confirmed, and a Confirm address button.](https://www.vestiarion.xyz/docs/guides/first-payment-address.png) *A changed address waits for a person to confirm it.* Before you confirm an address, check it with the counterparty through a different channel from the one the change came through. The ledger records who changed the address, from what to what, and who confirmed it. A contractor's verified milestone waits the same way: it stays verified, and the first cycle after someone confirms the address decides it. Anyone who may not add records sees "Only an owner or admin of this workspace can add counterparties." instead of the form. ## 2. Add an invoice Open **AP / AR** and open **New invoice**. Choose **Enter one invoice**, **From a document** to have the model read it from a PDF or an email (see [Read an invoice from a document](#read-an-invoice-from-a-document)), or **Import CSV** to add several from a file. - **Direction**: **Payable**, for an invoice you pay. Choosing a counterparty sets it: **Receivable** for a client, which pays you, and **Payable** for anyone else; the line under it says which it is. A payable to a client is never paid by the agent: it waits for a person, who pays it in **Approvals** if it is a refund. - **Counterparty**: the one you just added. - **Amount**: within the counterparty's payment limit. The agent does not pay an invoice over the limit; it holds it for you. - **Currency**: USDC, or EURC for a vendor that bills in euros. See [Invoices in EURC](#invoices-in-eurc). - **Due date**. - **Early-payment discount (%)** and **Discount deadline**, if the vendor offers one. See [When the agent pays](#5-when-the-agent-pays). - **Memo**, if you like. - **PO reference**, and tick **Goods or services received**. Without confirmed receipt, or without a purchase order from a counterparty that needs one, the three-way match is incomplete, and the agent asks for more information instead of paying. Every counterparty needs a purchase order until it is marked as paid without them: see [Pay a counterparty without purchase orders](#pay-a-counterparty-without-purchase-orders). ![The New invoice form on the AP / AR page, on the Enter one invoice tab, filled in: Direction Payable with the line Money you owe Northstar Studio, Counterparty Northstar Studio · vendor, Amount 12.50, Currency USDC, Due date 10/15/2026, Early-payment discount and Discount deadline left blank, Memo October design retainer, PO reference PO-2207, and Goods or services received ticked. The Add invoice button is below.](https://www.vestiarion.xyz/docs/guides/first-payment-invoice.png) *A payable within the counterparty's limit, with its PO reference and the goods received.* Choose **Add invoice**. The page confirms with "Invoice added for *Name*. The agent usually decides on it within a minute." As the form says, "The agent usually decides on a payable within a minute of adding it." ### Read an invoice from a document Instead of typing an invoice in, choose **From a document**. Choose a PDF or an email, or paste the invoice's text under **Or paste the invoice's text**, then choose **Read invoice**. The workspace's model reads the vendor, the amount, the currency, the due date, the purchase order, any early-payment discount and the address the invoice asks to be paid to. It fills in the invoice form below with them. Nothing is added until you check the form and choose **Add invoice**. ![An invoice from Northstar Studio read from a PDF by DeepSeek, under New invoice on the AP / AR page. A callout asks for every field to be checked and warns that the invoice asks to be paid to a different address than the one on file. Below it, the invoice form is filled in: Counterparty Northstar Studio · vendor, Amount 12.50, Currency USDC, Due date 10/15/2026, Early-payment discount and Discount deadline left blank, Memo October design retainer, PO reference PO-2207, and Goods or services received left unticked.](https://www.vestiarion.xyz/docs/guides/first-payment-document.png) *An invoice read from a PDF, with its address checked against the one on file. Nothing is added until you choose Add invoice.* What the model read is checked before you see it: - An amount, purchase order or address that the document does not contain is left blank: "The amount the model gave is not in the document, so it was left blank." - An amount written with a decimal comma, as much of Europe and Vietnam write money, is read as such: 3,50 is 3.50 and 1.200,00 is 1200.00. A comma before three digits is a thousands separator, so 1,250 is 1250. - A due date read from a date written in numbers whose day and month could swap gets a note naming both days, the one taken first: "The due date, 3 November 2026, was read from 03/11/2026, which can also mean 11 March 2026. Check it against the invoice." - When you type over the amount that was read, the form names what was read: "The invoice was read as 12.50: check this amount before you add it." - The counterparty is matched by its address on file, or else by its name. When none matches, the form says so: add it on **Counterparties** first, or choose one. - When the invoice asks to be paid to another address than the one on file, the form warns you. The agent pays the address on file; a document never changes it. - **Goods or services received** is never ticked for you: "A document cannot say this; tick it only if you received them." - An early-payment discount whose percent the document states is filled in even when its deadline cannot be used, and the form then asks you for the deadline: "The discount deadline was left blank: the invoice does not give one that could be read. Enter the last day the discount applies, or clear the discount." A PDF is read from its text, and an email (an `.eml` file) from its own text and the PDFs attached to it. A scanned PDF has no text: "This PDF has no text to read; it may be a scan. Paste the invoice's text instead." A file can be up to 4 MB, and a workspace can read five a minute. The document itself is not kept. The invoice's ledger entry records the document's SHA-256 hash, which model read it, and which fields you changed. ### Invoices in EURC A vendor that bills in euros can be paid in EURC, Circle's euro stablecoin, on Arc testnet. Choose **EURC** under **Currency** on the invoice form, or put `EURC` in a CSV row's `currency` column. As the form says, "A EURC payable is paid in EURC, and checked against the payment limit at its USDC value." - **The rate.** Payment limits stay in USDC. Before it decides a EURC payable, the agent asks Circle's Stablecoin Service what the amount is worth in USDC on Arc testnet, and checks that value against the limit. The decision's ledger entry records the rate and when it was quoted. It is the testnet market's rate, not a reference exchange rate. - **No rate, no payment.** When the quote does not answer, the payable is held for a person, and its card shows **USDC value** "no rate". If the model had decided to pay it anyway, the card's "Blocked by code, not by the model" band names the rule, `fx.rate_unavailable`. You can still approve it on **Approvals**. - **Decided again when the rate comes back.** Arc testnet's route comes and goes. Every 5 minutes, Vestiarion asks Circle again for each EURC payable held for want of a rate, of a swap, of a swap within its cost cap, or of a value within the limit. Once a quote clears what held it, the agent takes it up again within a minute and decides it with every check, with no one pressing anything. - **On the payable.** Under **How the agent decided**, the reopen step names what came back, for example "A EURC rate is quoted again: 0.5 EURC is worth 0.607631 USDC at 1.215262 USDC per EURC, where there was none at the decision". - **In the activity line.** For example "Paid Loto 0.50 EURC · decided again once a EURC rate was quoted". - **In the ledger.** The reopen and the decision after it record the rate before and after, and the decision they follow. - **Not a loop.** A route that comes and goes reopens a payable at most once in 30 minutes. **Return to agent** on **Approvals** still works at any time. - **Paid from EURC.** A EURC payable is paid in EURC from the operating wallet; USDC is never sent in its place. - **When the wallet's EURC is short:** the agent can buy the EURC with USDC first (next point). Otherwise the payable is held. If the model had decided to pay it anyway, the band names the rule, `treasury.insufficient_eurc`. - **Testnet EURC:** get it at [faucet.circle.com](https://faucet.circle.com) for the operating wallet's address, the same way as USDC. - **How much EURC the wallet holds:** the **Balance on-chain** tile on **Console** shows it under the USDC, read from Arc testnet each time the tile refreshes. - **Paid from USDC by a swap.** In a live workspace, when a EURC payment is due now and the wallet's EURC is short of it, the agent is offered a swap of USDC for the EURC it needs. - **Where.** The swap goes through Circle's Stablecoin Service, the swap service behind App Kit, on Arc testnet. - **How much.** It is sized so that its minimum covers what is missing. EURC above that stays in the wallet. - **Who decides.** The model decides whether to pay with it, weighing what it costs. - **What code refuses.** A swap that costs more than 3% above the quoted rate (`fx.swap_cost_above_cap`), or that would leave the USDC below what falls due in USDC within 7 days (`fx.swap_usdc_short`). - **How it runs.** From the operating wallet, before the EURC transfer. It is recorded as its own `fx_swap` ledger entry, with its rate and its transaction on Arc testnet. The card shows **Funded by swap**, linked to that transaction. - **When it runs.** A payable not due yet is scheduled, and the swap is made on its day. A swap still in flight at Circle is finished by the next cycle before the payable is decided again. A sandbox never swaps. - **Repeats.** A EURC bill sent again in EURC is refused like any repeat. A purchase order billed once in USDC and again in EURC is shown to the agent as a re-bill to confirm. - **Totals stay USDC.** Paid out to date, the treasury buffer and the forecast count USDC only. ### Pay a payee on another chain A vendor who wants USDC on Base, Arbitrum or Ethereum Sepolia is still paid from the operating wallet on Arc testnet. As the form says, "Another chain is paid from Arc through CCTP, for a fee." Choose the vendor's chain under **Chain**, and give their address on that chain under **Payment address**. Only a vendor can be paid on another chain: a contractor's milestones are released on Arc testnet. A payee link sent to such a vendor asks for their address on that chain. - **How it is paid.** The agent burns the USDC on Arc through Circle's Cross-Chain Transfer Protocol (CCTP), and Circle's Forwarding Service mints it to the payee on their chain. The payee is minted the invoice amount, and the fee comes on top of it, from the operating wallet. - **The fee.** Before it decides, the agent asks Circle what the transfer costs, and weighs it. The fee depends on the chain: on 2026-10-01 it was about 0.05 USDC to Base Sepolia, 0.13 to Arbitrum Sepolia and 1.85 to Ethereum Sepolia. A payout whose fee is above 10% of the invoice is held for a person, and so is one Circle gave no fee for. - **From a Gateway balance.** A live workspace can also hold a balance in Circle Gateway, which pays out on another chain at once. - On **Treasury**, an owner or admin opens **Manage** under **Gateway balance**, enters an amount in **Amount to move from the operating wallet (USDC)**, and chooses **Fund Gateway**. The first funding creates the workspace's Gateway signer, a Circle wallet that signs Gateway's transfers; Circle holds its key. USDC moved into Gateway stays there until it is paid out. - For each payout to another chain, the agent asks both Gateway and CCTP what it costs. It pays from the Gateway balance when that balance covers the payout and Gateway's fee is no higher than CCTP's; otherwise it pays through CCTP. - The card names the route the payout took, under **Payee's chain**, with its fee against the other route's, and links the mint on the payee's chain. Once a payout has started on a route, every later attempt uses that route. - **USDC only.** An invoice in EURC to a payee on another chain is held: only USDC crosses chains. - **In flight, then paid.** The invoice reads "Payment in flight" from the burn on Arc until the mint lands, usually within half a minute. Its card then reads "Settled on *chain*", naming the chain the money landed on, such as "Settled on Base Sepolia", and links both: the burn on arcscan, and the mint on the payee's chain. A payment still not minted two hours after it was sent is held for a person. - **Approving one.** On **Approvals**, the card names the payee's chain. **Approve and pay** takes the route the agent would: Gateway when the Gateway balance covers the payout and Gateway costs no more, CCTP otherwise. A payout an earlier attempt started keeps its route. The confirmation names the route and its fee, for example "The Gateway fee, about 0.163 USDC, comes on top, from the Gateway balance." A CCTP payout needs the operating wallet to hold the amount and the fee; a Gateway payout needs the Gateway balance to. ## 3. Run a cycle A cycle is one run of the agent: it screens counterparties, reads open invoices, and pays, schedules, holds or flags each one. Adding a payable starts a cycle, usually within a minute, so the invoice is decided without anyone pressing a button, and the pages show the decision on their own. The agent also starts one when a person returns a payable to it, confirms a changed address, verifies a milestone, or resumes it. One cycle runs at a time: if one is already running, the next waits for it, and **Run cycle now** says so instead of starting a second. A live workspace also runs a cycle every 6 hours on its own, for what time alone changes, such as due dates and screening. To run one yourself, open **Treasury**, the console, and choose **Run cycle now**. Only owners and admins have this button. When the cycle ends, a message says "Cycle complete at *time* · *n* decisions logged." and the console shows a report of what the cycle did. ### Watch the agent work The line at the top of every workspace page says when the last cycle ran. While a cycle runs, it reads "The agent is working · *n* s" instead, counting the seconds, and on **AP / AR** each payable not yet decided reads **Deciding now**. When the agent has decided, a message says what it did, one per decision, how long after your action, who decided and what was checked. For example: - "Paid Jiren 0.30 USDC · 28 s after details were added.", then "DeepSeek decided, as the written policy would. Checks passed: purchase order and goods, the 30.00 USDC limit, screening, the spending-limit contract." Under it, **How it decided** opens the payable's steps on **AP / AR**, and **View on Arcscan** its transaction. - "Code stopped paying Centronex 0.35 USDC · 19 s after it was added.", then "DeepSeek decided to pay it; code stopped it: the agent's spending limit has no room today, and the agent pays it once there is." Under it, **Decide in Approvals**. When a cycle decides more than two things, one message says "The agent made *n* decisions." and **See them** opens the console's report of that cycle. Either way the page shows what changed at once, without a reload. A page tells what happens while it is open: a cycle started by your own action, by another member's, or by the 6-hourly schedule. It watches closely for a minute and a half after you add or change something, and less often otherwise. ## 4. Read the decision and its reasoning On **AP / AR**, the tiles at the top count what needs you, what is due within 7 days, what is overdue, and what is still to pay. Under **Payables**, each invoice is one row, in three groups: **Needs you** (held, or refused by code), **Upcoming** (not yet paid, with the day it pays or falls due) and **Paid and closed** (the latest ten; **Show all** lists every one). Open a row to see its decision as a card. The console's **Stopped** section also shows the latest decisions the agent stopped. A card shows: - the outcome: "Settled on Arc", "Scheduled for *date*", "Held for you" or "Refused by guardrail"; - the amount and the counterparty; - **Agent’s reasoning**: why it decided as it did, citing the facts it used, such as the amount, the PO reference, the risk level and the balance; - the evidence it checked, the ledger entry (`audit #` and its number), and, for a payment, the transaction hash. ![The Payables list on AP / AR, its Paid and closed group holding one row: Northstar Studio, October design retainer, Due Oct 15, 2026, 12.50 USDC, Settled on Arc. The row is open on its decision card: Pay Northstar Studio, 12.50 USDC, marked Settled on Arc. The agent's reasoning reads: Paid 12.50 USDC to Northstar Studio: PO-2207 matches and the work was received, Northstar Studio screened clear with a 50.00 USDC limit, and the operating wallet holds 20.00 USDC. Below are How the agent decided, folded, the evidence chips PO, Goods received, Risk, Limit and Due, then the link to ledger entry 5 and the transaction hash.](https://www.vestiarion.xyz/docs/guides/first-payment-decision.png) *A settled payment: the agent's reasoning, the evidence it checked, the ledger entry and the transaction.* When the model decided to pay but a guardrail stopped it, the card also has a "Blocked by code, not by the model" band. The band names the rule, the amount attempted and the amount allowed. At the foot of a payable's card, **How the agent decided** opens its steps, oldest first, each a signed entry in the ledger: who added it, what a person changed, each decision with who decided and whether the written policy agreed, what it checked (the purchase order and goods, screening, the amount against the limit, duplicates), what code refused, what the spending-limit contract on Arc said, and the transaction that reached Arc testnet. Each step shows its time to the second, how long after the step before it, and the number of its entry in the audit log. ![How the agent decided, opened on a paid payable to Northstar Studio, 5 steps, each check on its own line: a person added it; DeepSeek decided to ask for more information before paying it, with No purchase order on file; 14 min later a person added purchase order PO-2215 and goods received; 14 s later the agent saw the facts change and took it up again; 12 s later DeepSeek decided to pay it, as the written policy would, with each check passed, the spending-limit contract on Arc allowing it, and the transaction.](https://www.vestiarion.xyz/docs/guides/first-payment-trail.png) *How the agent decided: each step a signed entry, with what was checked.* A payable the agent stopped also says what you can do about it, on the console's **Stopped** section as on **AP / AR**. Under the card, a line names the cause and the way through, for example "It is above CME's payment limit. Pay it in Approvals, or raise the limit." **Decide in Approvals** opens Approvals at that payable's card. Owners and admins also get the page that removes the cause: - **Edit limit** or **Confirm address** opens the counterparty's row on **Counterparties**, already open; - **Purchase orders** opens the counterparty's row too, when code refused a payable for a missing purchase order, for a counterparty that is in fact paid without them; - **Review screening** opens the counterparty's row, for a counterparty screened high risk, where a wrong match is marked **Not this person**; - **Spending limit** opens the agent's spending limit on **Treasury**, when it had no room for the payment; - **USYC reserve** opens the reserve in **Settings**, when the operating wallet did not hold the cash a payment needs and the reserve could not cover it: bring cash back there, or add USDC to the operating wallet, and the agent decides the payment again on its own; - **Treasury**, when the operating wallet is short of EURC or the Gateway balance of a payout. A stop only a decision resolves, such as a payout fee above 10% of the invoice or a possible duplicate, says so and offers **Decide in Approvals**. A payable to a client is one: the agent never pays a client on its own, so the card says to pay it in **Approvals** if it is a refund, or to reject it and add it as a receivable. A payable missing its purchase order or goods receipt offers **Add details** (see "Add what the agent was missing" below). ![The console's Stopped section with one card: Tried to pay Northstar Studio, 75.00 USDC struck through, Refused by guardrail. The band Blocked by code, not by the model names the rule counterparty.payment_limit, 75.00 USDC attempted against 50.00 USDC allowed. Under the agent's reasoning, How the agent decided, folded, and its evidence, the card's last line reads: It is above Northstar Studio's payment limit. Pay it in Approvals, or raise the limit. The buttons are Edit limit and Decide in Approvals.](https://www.vestiarion.xyz/docs/guides/first-payment-stopped.png) *A payment code refused, with the way through: raise the limit, or decide it in Approvals.* ## 5. When the agent pays An invoice does not have to be paid the moment it is decided. At intake, a payable can carry an early-payment discount: **Early-payment discount (%)** and **Discount deadline**, alongside its other fields. On a cycle, the agent chooses not only whether to pay an invoice but when, within a bound code enforces: never later than the invoice's due date. A discount needs its deadline. Once you enter a percent, **Discount deadline** is no longer optional. As the form says, "Needed with a discount: the last day it applies, on or before the due date." An invoice with a percent and no deadline is not added, and the form says under the field: "Enter the last day the discount applies, on or before the due date, or clear the discount." A CSV row's `early_pay_discount_pct` and `discount_deadline` columns follow the same rule: both, or neither. - With an early-payment discount still available, it schedules the payment for the discount's last day, and pays the discounted amount then. - Otherwise, it schedules the payment for the due date, holding the cash until it is owed. - A payable due today, or overdue, is paid at once rather than scheduled. - Before it decides, the agent brings back from the reserve what today's payments need beyond the operating balance: the payables due today, and the verified milestones waiting to be paid. - If the cash still cannot cover a payable after the payables that fall due on or before its day, the agent holds it rather than scheduling or paying it. It decides the payable again at the first cycle after cash comes in. A scheduled invoice's decision card reads "Scheduled for *date*", with the agent's reasoning for choosing that day. On the day itself, the next cycle checks everything again before anything moves: the counterparty's risk level, its payment limit, whether its address is confirmed, and whether the agent is paused. Pausing the agent stops a scheduled payment exactly as it stops any other. An invoice with an early-payment discount shows its terms on its card as "*pct*% off if paid by *date*". Until the agent has decided a payable, its card reads "Not yet decided", and while its transfer is in flight, "Payment in flight". The console's **Scheduled payments** section lists what the agent will pay next, soonest first: the date, the counterparty, the amount it will actually transfer — discounted, when the discount will still apply that day — and why. ### Pay something every period A retainer, a subscription or a monthly fee does not need a new invoice each time. Under **New invoice**, open **Recurring** and fill in the form: - **Pay**: a vendor or contractor. - **For**: what it is for. - **Amount each period**, in USDC or EURC. - How often: **Every** 1 or more days, weeks or months. - **First due date**, and a **Last due date** if it ends (optional). - A **Contract or PO reference** if you have one. Above the button, the form reads the schedule back as you fill it in, for example "Pays Jiren 0.3 USDC every day, from Oct 2, 2026 to Oct 3, 2026: 2 payments.". Check the period and the number of payments there before you go on. Choose **Set up recurring payment**. The setup is signed in the audit log as `recurring_payable_created`. - **Each period becomes an invoice as it comes near.** At most a week before its due date, a cycle creates it: a daily one on its day, a weekly one 6 days ahead, a monthly one 7 days ahead. The agent then decides it like any other: when to pay, within every guardrail. Each one is signed as `recurring_invoice_created` and appears under **Payables**, named after the period. - **Months are counted from the first due date.** A payment due on the 31st falls on the last day of a shorter month and goes back to the 31st after. - **Periods of one schedule are never taken for duplicates of each other.** The same retainer billed every week is not refused as a repeat bill. An invoice typed by hand that repeats one of them still is. - **Delivered every period** is ticked by default: you confirm the work or service continues, so each period's invoice counts as received. Untick it to confirm each period yourself. - **Stopping.** The **Recurring payments** list shows each schedule's next due date and status. **Stop** ends it, signed as `recurring_payable_stopped`; invoices it already created stay as they are. ### Cap what the agent pays on its own The console's **Agent spending limit** panel shows what the agent has paid on its own today (UTC) and in the last 7 days. To cap it, an owner or admin chooses **Set limit** and fills in **Per day (USDC)**, **Per 7 days (USDC)**, or both, then **Save limit**. A blank figure means no limit. Each change is recorded in the audit log as `agent_budget_changed`. - **Past the limit, a payment waits.** A payment or milestone release that would take the agent past either figure is held before anything is sent. Its card's "Blocked by code, not by the model" band names the rule, `workspace.outflow_budget`, with the amount attempted and what the limit had left. - **A person's approval does not count.** A held payable waits in **Approvals**, and what you approve there is never stopped by the limit, nor counted against it. - **It comes back by itself.** When the limit has room again (a new day, a higher figure, or no limit), the agent decides the held payment again. Raising a figure brings it back within a minute, and the audit log records the reopening with "the agent's spending limit has room for it again". - **Schedules are checked on their day.** Scheduling a payment does not count against the limit; paying it does. ### Enforce the limit on Arc The limit above is checked in Vestiarion's code. Under its figures, the panel's **On Arc** part makes the same limit binding on Arc testnet too: the agent's own payments then leave through a contract that refuses anything past it, whatever the agent decided. In a live workspace with a daily or 7-day figure, an owner or admin chooses **Enforce on Arc**. - **What it sets up.** A contract on Arc testnet that holds the figures, and a wallet of the agent's own that holds no USDC: Circle's Gas Station pays its gas. The operating wallet keeps its money and approves the contract to draw from it. Setting up sends 0.1 USDC from the operating wallet to the deployer for gas, and is signed in the audit log as `spending_limit_enforced`. If it is interrupted, the button reads **Finish enforcing on Arc** and picks up where it stopped. - **The agent's payments go through the contract.** Each payment the agent makes on its own in USDC on Arc testnet is sent by its own wallet to the contract, which draws the amount from the operating wallet. The contract refuses a payment past the daily figure (the current UTC day) or past the 7-day figure (that day and the six before it), and it pays each invoice or milestone once. The panel shows what the contract counts, **Paid through it today** and **In the last 7 days**, with links to the contract and to the agent's wallet on the Arc testnet explorer. - **Vestiarion asks the contract first.** Before each payment, it asks the contract whether it would pay, without sending anything. If the contract would refuse, nothing is sent: the payment is held with the rule `workspace.onchain_limit`, and its card shows the contract's own figures. The check in code still runs first, so this hold means the two disagreed. Each decision records the contract's answer as `onChainLimit`. - **What the contract cannot carry waits for you.** While the limit is enforced on Arc, the agent does not send a payment in EURC, or one to a payee on another chain: it is held with the rule `workspace.onchain_limit_route`, and you pay it from **Approvals**. A milestone locked in escrow is released from the escrow as before; its money left the operating wallet when it was locked. - **People's payments do not go through it.** **Approve and pay** and **Pay now** are transfers from the operating wallet, as before, outside the agent's limit. - **Changing the figures changes the contract first.** The new figures are saved once Circle confirms the change on Arc testnet, and `agent_budget_changed` records its transaction. While the limit is enforced, a figure must stay set. - **Turning it off.** **Turn off on Arc** sets the operating wallet's approval to 0, so the contract can draw nothing, and the agent's payments are checked in code only. The contract stays on Arc testnet; **Enforce on Arc** uses it again. It is signed as `spending_limit_unenforced`. - **The contract.** Its source, its functions and the address it runs at in production are on [Contracts on Arc testnet](https://www.vestiarion.xyz/docs/contracts). - **Who holds the keys.** The operating wallet, the agent's wallet and the deployer are developer-controlled wallets: Circle signs for them on Vestiarion's behalf. The contract keeps the agent, and the code that pays for it, within the limit. It does not protect against someone who took over Vestiarion's server, who could also sign for the operating wallet. ![The Agent spending limit panel with 3.80 USDC left, 1.20 of 5.00 USDC paid today and 3.70 of 20.00 USDC in the last 7 days. Under it, On Arc: enforced on Arc, with links to the contract and the agent's wallet, the contract's own count of what was paid through it today and in the last 7 days, and Turn off on Arc.](https://www.vestiarion.xyz/docs/guides/first-payment-onchain-limit.png) *The limit enforced on Arc, with what the contract itself counts.* ### The agent checks a new payee's history Before the agent pays an address for the first time, it can buy that address's payment history across Vestiarion. The answer says how many other workspaces here have paid the address, how many payments were confirmed, and when the first and last were made. It names no workspace and no amount. The model weighs it with the payment's other facts: an address no business here has paid deserves a closer look. Code does not act on it. The agent pays for each answer itself, 0.001 USDC over **x402**, the HTTP payment standard, settled through Circle Gateway on Arc testnet. Vestiarion is the seller. Today no other x402 seller accepts Arc testnet, so the agent buys only from Vestiarion. - **The service budget.** The agent pays from its own small balance, never from the operating wallet. On the console's **Service budget** panel, an owner or admin opens **Manage**, enters an amount (at most 1 USDC at a time) and chooses **Add to the budget**. The operating wallet moves it into Gateway for the workspace's Gateway signer in one transaction. The panel appears once the workspace has funded Gateway, which creates that signer. Each deposit is signed as `service_budget_funded`. - **When it buys.** A payable or milestone must be waiting for a decision, the counterparty's Arc address must be confirmed, and this workspace must never have paid that address. An answer counts for 7 days. - **Code's limits.** The agent pays only Vestiarion's own endpoint, only the offer it expects, at most 0.01 USDC a call, at most 0.05 USDC a day, and at most 3 purchases a cycle. It buys nothing while it is paused. A purchase code refuses is not tried again for a day. - **In the audit log.** A purchase is `service_purchased`, with the price, the authorization's nonce, Gateway's settlement and the answer. A refusal is `service_purchase_refused`, with the rule, and a failure is `service_purchase_failed`. The decision that follows carries the answer in its observed facts as `addressHistory`. ## 6. Approve a held payment **Approvals** lists every payable the agent would not pay on its own, oldest due date first, each marked held, flagged or awaiting information and shown with the agent's reasoning. When the list is empty, it says "Nothing is waiting for a decision." Owners, admins and approvers decide each one: - **Approve and pay** starts the transfer. A dialog asks you to confirm, and **Pay now** sends it at once. The ledger records who approved it. - **Reject** marks it not paid. You can give a reason, which is kept in the ledger. - **Return to agent** sends it back to pending, and the agent usually decides it again within a minute. ![An approval card on the Approvals page: Bluebird Logistics, due Oct 9, 2026, 8.00 USDC, marked Held, with the reasoning: Held 8.00 USDC for Bluebird Logistics: no receipt is recorded for PO-2213, so the three-way match is incomplete, and below it Pays to and the counterparty's address. The buttons are Approve and pay, Reject and Return to agent.](https://www.vestiarion.xyz/docs/guides/first-payment-approval.png) *A held payable, with the three decisions a person can make.* **Approve and pay** is disabled in two cases, and the card says why, and what you can do instead: - **You entered this invoice.** Beside the disabled button, the card says "You created this invoice". The person who adds an invoice cannot also approve it, so a payment made by hand always has two people behind it: another owner, admin or approver decides it. The card says so, and you can still reject it or return it to the agent. **See who can approve** opens **Members**. When a rule stopped the agent, the card also says what happens without anyone approving: for the agent's spending limit, "The agent pays it on its own once its spending limit has room: the next UTC day, or sooner if an owner or admin raises the limit." For a payment held for want of cash, "The agent decides it again on its own once cash comes in: USDC added to the operating wallet, or brought back from the reserve." Owners and admins also get the page that removes the cause, here **Spending limit**. - **You gave this payee's address.** The first payment to an address needs two people behind it. Where payments are real, the agent never makes a first payment that only one person stands behind, and holds it as `counterparty.new_payee`. That includes an address one member typed in, or changed and confirmed alone. Whoever gave the address cannot approve that first payment either: beside the disabled button the card says "You gave this payee's address", and another owner, admin or approver pays it. - **Already two people.** An address the payee sent through a payee link or GitHub and a member confirmed has two parties behind it, so the agent pays it on its own. So does one a second member confirmed. - **After the first payment,** the agent pays the address on its own, and decides again any other bill to the same payee that waited for it. - **Alone in a workspace.** The only approver may pay the first payment to an address they gave, as for an invoice they entered. - **Screened high risk.** A counterparty screened high risk is never paid, not even by approval. If the screening matched someone else, **Review screening** opens the counterparty's row on **Counterparties**, where you mark the match **Not this person**; the agent then decides the invoice again. ![An approval card for Bluebird Logistics, 3.00 USDC, Held, which the viewer entered: Approve and pay is disabled with You created this invoice beside it. Under the agent's reasoning, a box titled You entered this invoice says that another owner, admin or approver must approve it, that the person who enters a bill never also approves it, and that the agent pays it on its own once its spending limit has room, with the buttons Spending limit and See who can approve.](https://www.vestiarion.xyz/docs/guides/first-payment-approval-yours.png) *A payable you entered: who must approve it, and what happens without them.* If you are the only member of the workspace who can approve payments (the only owner, admin or approver), there is nobody else to approve what you add, so **Approve and pay** stays enabled on your own invoices and the card says "You entered this invoice. You are the only person in this workspace who can approve payments, so you can approve it yourself, and the ledger records that you did." The confirm dialog adds "It also records that you entered it yourself, as the workspace's only approver." In the audit log, the `approval_paid` entry ends "(entered and approved by the workspace's only approver)". Every other check still applies: a high-risk counterparty, a changed address and a short balance are still refused. Once a second member can approve payments, the rule applies again, and each of you approves what the other entered. ![The same approval card, seen by the workspace's only approver, who entered the invoice: under the reasoning it says You entered this invoice. You are the only person in this workspace who can approve payments, so you can approve it yourself, and the ledger records that you did. Approve and pay is enabled, beside Add details, Reject and Return to agent.](https://www.vestiarion.xyz/docs/guides/first-payment-approval-own.png) *A payable you entered, when you are the workspace's only approver.* If a payment attempt fails, the card says "The last payment attempt failed: " and Circle's reason, then "Approving sends a new transfer." Fix the cause first if you need to — for example, fund the operating wallet — then choose **Approve and pay** again to send it. Vestiarion never sends a second transfer while the first could still settle: a payment still in flight on Arc testnet instead says "The payment is still in flight on Arc testnet. It cannot be rejected or returned until Circle settles it; approving checks it again." Only **Reject** and **Return to agent** go away then — **Approve and pay** stays: for an invoice waiting here, approving is how its in-flight transfer is checked again, and it never sends a second one. Sometimes Circle does not answer a payment at all, and Circle may still have taken it. Vestiarion then looks for it on Circle before anything else, and the payment stays in flight until it knows. If it waits here meanwhile, the card says "Circle did not answer when this payment was sent, so it may have taken the transfer. Approve and pay, Reject and Return look for it on Circle first: Approve and pay records it if Circle has it, and sends it only once Circle shows none." Once Circle has listed nothing for 15 minutes after the send, it was never taken: approving sends it then, and **Reject** or **Return to agent** closes it. If every page says "Payments are switched off for every workspace right now.", whoever runs this deployment has stopped all payments. The agent runs no cycle, and **Approve and pay** says the same, until they turn payments back on. Everything else still reads as usual. **When the operating wallet falls short.** A payment leaves from the operating wallet, with a CCTP payout's fee on top. When the wallet holds less, and the USYC reserve holds the rest, **Approve and pay** brings the difference back from the reserve first, then pays: - **Before you confirm,** the dialog says so, for example "The operating wallet holds 0.184239 USDC, so about 0.215761 USDC comes back from the USYC reserve first." It reads the balances last recorded; the approval reads them again. - **After paying,** it says "Paid. 0.215761 USDC came back from the USYC reserve first." The audit log records the move as `cash_brought_back`, by you, before the `approval_paid` entry. - **When the two together fall short,** nothing moves, and the card says what each holds. - **If nothing came back from the reserve,** nothing is sent. The payable waits in **Approvals** as it was, and any approval already given still stands. When Arc testnet has not confirmed the redemption yet, it says so, and asking again a minute later finds that same redemption rather than sending a second one. - **A payout to another chain through CCTP** brings back a fifth more of the CCTP fee as well, since the fee is read again just before the transfer; what is left of it stays in the operating wallet. One whose fee could not be read is not covered. **Pay now** on a held milestone works the same way, except for one whose USDC is being locked in escrow. A Gateway payout is paid from the Gateway balance, which the reserve does not fund. ### Two approvals above a figure An owner can make larger payments need two people. In **Settings**, under **Two approvals**, fill in **Payments above (USDC)** and choose **Save**. The section then says, for example, "Payments above 500 USDC need two approvals." Turning it on, or lowering it, needs two people who can approve payments. With fewer, it says "Two approvals need two people who can approve payments. Add an approver on Members first." Each change is signed in the audit log as `approval_policy_changed`. - **The agent never pays above it.** Code holds a payable the agent would pay or schedule above the figure, or a milestone it would release, before anything is sent. The rule is `workspace.two_approvals`. A EURC payment counts at its value in USDC. - **The first approval sends nothing.** A card above the figure says "No one has approved it yet.", and its button reads **Approve**. Choosing it records your approval, signed as `approval_given` (`milestone_approval_given` for a milestone), and says "Approved. One more approval, by another person, pays it." - **The second approval pays.** Another owner, admin or approver sees who approved it, and **Approve and pay**. Their approval pays it, and the `approval_paid` entry names both approvals. One person cannot give both: the button is disabled, with "You approved it" beside it. - **On Contractors**, a held milestone above the figure says the same, and **Pay now** reads **Approve** until the approval that pays it. - **An approval is of one payment.** It holds the amount, the currency and the payee's address. If any of them changes, or the person who gave it can no longer approve payments, it no longer counts. **Reject**, **Return to agent** and **Close without paying** clear it. A transfer that fails needs two approvals again. - **Who gives them.** As many of the two approvals as can must come from people who neither entered the payment nor gave a new payee's address. Those two people give the rest only when no one else can approve. In a workspace with one person who can approve, the button is disabled with "Needs a second approver" beside it: nothing above the figure is paid until the figure is raised or someone else can approve. - **Raising it or turning it off** brings payments held for two approvals back to the agent within a minute. **Turn off** asks first: "Turn off two approvals?" - **A payment Circle never took** is sent again by the agent only when one approval may pay it, or two people's approvals of it paid it. Otherwise it waits in **Approvals**, or on **Contractors**, for two people. This covers a figure set or lowered after the first send. Raising the figure does not bring such a payment back: two people approve it, or one returns it to the agent. - **In Slack**, a card keeps its buttons after the first approval, with who approved it, so a second person can approve it there. ### Add what the agent was missing The agent pays an invoice on its own only when its three-way match is complete: the goods or services received and, unless its counterparty is paid without them, a purchase order on file. Without them it asks for more information, and the card is marked **Awaiting information**. If the model chooses to pay or schedule it anyway, code refuses, and the card waits the same way. When you have what it asked for, you do not need to approve the payment yourself: give the agent the facts, and it decides again. Owners and admins choose **Add details** on the card. The dialog offers only what the invoice is missing: a **PO reference**, the **Goods or services received** box, or both. Fill them in and choose **Add details**. A message says "Details added. The agent usually decides it again within a minute." ![The Add details dialog over an approval card marked Awaiting information for Bluebird Logistics, 8.00 USDC. It explains that the agent pays an invoice on its own only with a purchase order on file and the goods or services received. The PO reference field holds PO-2213, Goods or services received is ticked, and the buttons are Cancel and Add details.](https://www.vestiarion.xyz/docs/guides/first-payment-add-details.png) *Adding the purchase order and the goods receipt the agent asked for.* On **AP / AR**, the payable waits under **Needs you**, and its row says what it needs: "Needs a purchase order and goods received", "Needs a purchase order" or "Needs goods received". A counterparty paid without purchase orders is never asked for one. Open the row: under the agent's reasoning, its card asks for what is missing and offers **Add details** there too, beside **Decide in Approvals**, which opens Approvals at that payable's card. Once you have added details, the row reads "Details added · the agent decides it again" until it does. ![AP / AR, under Payables, Needs you: one row for Northstar Studio, November design retainer, Needs a purchase order and goods received, due Oct 15, 2026, 12.50 USDC, marked Awaiting info. Opened, its card shows the agent's reasoning, the evidence PO none and Goods received no, How the agent decided, folded, and at the bottom: Add the purchase order and confirm the goods or services were received, and the agent decides it again, with the buttons Add details and Decide in Approvals.](https://www.vestiarion.xyz/docs/guides/first-payment-needs-you.png) *A payable waiting for its purchase order, on AP / AR.* - **The agent decides it again.** Until it does, the card says what you added, beginning "Since the agent stopped it", and ends "The agent decides it again at its next cycle, usually within a minute." That cycle starts within seconds. The agent sees that the facts changed since its decision, records `invoice_reopened` with what changed, and decides the invoice again with every check, as it would a new one. It may pay it, schedule it, or hold it again for another reason, such as the payment limit. - **Only what is missing.** A PO reference already on file, or goods already marked received, cannot be changed here. - **Recorded.** The ledger records what you added, and who added it, as `invoice_details_added`. A payment made after it is not counted as "Paid on time with no person involved" on [www.vestiarion.xyz/open](https://www.vestiarion.xyz/open). - **When it is offered.** Approvers decide payments but do not enter invoices, so they do not see **Add details**. It is not offered once a payment for the invoice was sent, or while someone else is deciding it. ### Tell the payee it was paid A business usually tells a supplier when it pays them. Give a counterparty a **Billing email**, when you add it or later on its row on **Counterparties**, where **Billing email** shows the address with **Add** or **Edit** (owners and admins; empty turns notices off). Someone you pay with **Pay a freelancer** gets them at the email you gave for the link. Each time a payment to it is confirmed on Arc testnet, whoever approved it, the agent or a person, Vestiarion emails that address from no-reply@vestiarion.xyz: "*Workspace* paid you *amount*", what it pays for (the invoice's memo and purchase order, or the milestone's title), the address it went to, when, and **View the transaction** on Arcscan. - **Real payments only.** Only a live workspace sends them: a sandbox's payments are simulated, and it emails no one. - **On Arc.** This version sends them for payments made on Arc testnet; a payee paid on another chain gets none yet. - **Once.** Each payment is told once. A notice that cannot be sent is tried again at the next cycle, for three days. - **Recorded.** Each notice sent is a `payment_notice_sent` entry in the audit log, with the address partly hidden. ### When the agent suggests a higher limit When people keep approving one counterparty's payments above its payment limit, the agent notices. It proposes a higher limit, so payments like these stop waiting for a person. - **When.** At least two payments approved above the limit in the last 30 days, and none rejected. The counterparty must be screened clear. - **What it proposes.** The model decides whether to propose and which limit. Code accepts a limit only between the largest approved payment and twice it; otherwise the proposal is the largest plus 10%, rounded up. - **Where it shows.** It appears at the top of **Approvals** under **Suggested by the agent**: the new limit, why, and each approval it rests on. The proposal is signed as `policy_proposal_made`. - **Accept** changes the limit exactly as editing it on **Counterparties** would. Payments held under the old limit are decided again within a minute. It is signed as `policy_proposal_accepted`. - **Dismiss** changes nothing, and is signed as `policy_proposal_dismissed`. The agent suggests a limit for that counterparty again only after a new approval above it. Only owners and admins accept or dismiss, as for any limit change. ## 7. See it on Arc and in the audit log **On the Arc explorer.** On a settled decision card, the transaction hash is a link. It opens the transaction on the Arc testnet explorer, at `https://explorer.testnet.arc.io/tx/` followed by the hash, in a new tab. **In the audit log.** The card's `audit #` link opens the entry in **Audit log**. As that page says, "Every decision is appended here, hash-linked to the one before it and signed with Ed25519." Expand an entry to see its hash, the previous entry's hash, its body hash, its signature and the full detail. **Verify hash chain** checks every signature and every link, and answers "Chain intact" when all of them hold. ![The Audit log page after verifying: 5 entries, the head entry and its hash, and a green Chain intact box reading 5 signatures and 4 links verified. Below, the ledger lists entries newest first, grouped by date, starting with entry 5, Paid 12.50 USDC to Northstar Studio.](https://www.vestiarion.xyz/docs/guides/first-payment-audit.png) *The ledger verified: every signature and every link holds.* To check a signature yourself, see [Verifying signatures](https://www.vestiarion.xyz/docs/webhooks/verify). To read the ledger from your own code, see [List ledger entries](https://www.vestiarion.xyz/docs/api/list-ledger-entries) and [Verify the ledger](https://www.vestiarion.xyz/docs/api/verify-ledger). ## 8. Share a receipt A payee, an accountant or anyone else can check a payment without an account: share its receipt. - **Sharing it.** On **AP / AR**, open a paid payable's row: one with a transaction on chain shows **Share receipt** to an owner or admin. It makes a link to a public page. Copy the link then: it is shown only once. - **What the page shows.** The amount, the payee's chain and address, and how it was paid, with the fee. It also shows the transaction, linked to its explorer; for a CCTP payout, the burn on Arc testnet as well. - **The three checks the page makes.** - **Signed by the paying workspace.** The receipt is a ledger entry the workspace signed when it was shared. Its signature, body hash and chain hash are checked, and checked again in the reader's browser. - **Recorded when it was paid.** The entry that recorded the payment is checked and found to name its transaction. Its content stays private. - **On the payee's chain.** The chain shows a transfer of the amount to the payee in that transaction. - **Check it yourself.** This section holds the signed entry, its hashes and the workspace's public key, for checking without Vestiarion. - **No names.** The page shows no business, payee or person names, and no reasoning. - **Changing the link.** A shared receipt shows **Receipt shared**: - **New link** makes a new link, and the old one stops working. - **Stop sharing** turns the link off. - A link that no longer works says so and shows nothing else. - **Which payments.** Only live payments have receipts: a simulated payment has nothing on chain to show. A CCTP payout can be shared once the payee's chain has minted it. ## 9. Get paid by a client A receivable is money a client owes the business. In a live workspace, an owner or admin can send the client a link to pay it on Arc testnet: 1. **Make the link.** On **AP / AR**, under **Receivables**, open the invoice's row and choose **Get paid on Arc**, then **Copy link** and send it. A new link replaces the old one. 2. **The client pays.** The link opens a page with the amount, the due date, what it is for, and the workspace's address on Arc testnet. The client sends exactly that amount from any wallet and can choose **I have paid**, which only asks Vestiarion to look now. 3. **The agent matches it.** Every cycle, and on **I have paid**, Vestiarion reads the transfers that arrived in the operating wallet from Circle. A transfer of the same currency and amount as an open receivable, arriving after the receivable was added, settles it: from the client's own address on file, or, from any address, when it is the only such receivable and its client was sent the link. The receivable becomes received with its transaction, and the ledger records `ar_received`. Two open receivables of the same amount, with nothing to tell them apart, stay open for a person. Only a matched transfer counts as money received. Testnet faucet drips and swaps also arrive in the wallet, and they settle nothing. The link stays on the invoice's row: **Copy link** copies it again at any time. A link made before October 3, 2026 cannot be shown again; **Make a new link** replaces it. ### Let the agent remind the client A client who forgets to pay can be reminded by the agent, by email, with the pay link. On **AP / AR**, under the open receivable, choose **Remind the client by email** (owners and admins, in a live workspace). It needs the client's **Billing email**: without one, the row offers **Add billing email**, which opens the client's row on **Counterparties**. From then on, each cycle, the agent decides whether to send a reminder now or to wait, and how firmly. Code sets the bounds: - **When.** From 3 days before the due date to 30 days after it, at most every 3 days, and at most 4 reminders. The written policy sends one 3 days before the due date, one on it, one 3 days after it and the last one 10 days after it; the model may wait up to 3 days at a time, and says why. - **How firmly.** Friendly at any time; firm only after the due date; final only 7 days after it, once 2 reminders went out. A firmer tone than allowed goes out as the firmest allowed, and the audit log says so. - **What it says.** A fixed text by tone: who asks, how much, what for, when it was due and how late it is, with **Pay on Arc testnet**, which opens the pay link. Nothing the model writes reaches the client. - **When it stops.** Once the agent matches the payment, or a person rejects the receivable, or after the final reminder. The row then says to follow up with the client yourself. ![AP / AR, under Receivables, Open: a row for Acme Retail, October retainer, INV-1042, 12.50 USDC, due Oct 10, 2026. Under it the pay link with Copy link and Make a new link, then Reminders: Reminders are on. Sent: Oct 7, 2026 (friendly), Oct 10, 2026 (friendly), and a Turn off reminders button.](https://www.vestiarion.xyz/docs/guides/first-payment-reminders.png) *A receivable whose reminders are on: the link, and the two reminders the agent sent.* The row shows the reminders sent so far, with their dates and tones, and until when the agent waits. **Turn off reminders** stops them. Each reminder is a signed `ar_reminder_sent` entry, each wait an `ar_reminder_deferred` one, and both appear under the invoice's **How the agent decided**. Turning reminders on or off is signed as `ar_reminders_on` or `ar_reminders_off`. --- # Pay a contractor for delivered work > Add a milestone, verify the work by hand or by a merged pull request, and let the agent release the pay. A milestone pays a contractor for a piece of work once that work is verified, instead of on an invoice's due date: five social posts delivered, a design approved, a pull request merged. You add the milestone, someone verifies the work (or GitHub does, for a pull request), and the agent decides on the release within a minute and pays it in USDC on Arc testnet. Start with a workspace that is live and has testnet USDC in its operating wallet; [Go live on Arc testnet](https://www.vestiarion.xyz/docs/guides/go-live) sets that up. Owners and admins add milestones and verify them. Owners, admins and approvers decide a milestone the agent held. Other members see every milestone and its decision, but have no controls here. ## Pay someone new in one step To pay someone who is not yet on file for work they have delivered, open **Contractors**, open **New payment**, and fill in **Pay a freelancer** with their details, not yours: **Freelancer's name**, **Freelancer's email** if you have it, **What they delivered**, the **Amount (USDC)** and, if there is one, a **Link to the work**. Choose **Set up payment**. Vestiarion then: - adds them as a contractor, with that amount as their payment limit, and screens them; - adds the work as a milestone, verified by you, with the note "Delivered work confirmed when the payment was set up"; - emails them a one-time link to add the address they want to be paid at, and shows you the same link to copy. **Copy link** is there if they have no email or it does not arrive. The same email is where they are told once they are paid, with the transaction (see [Tell the payee it was paid](https://www.vestiarion.xyz/docs/guides/first-payment#tell-the-payee-it-was-paid)). ![New payment opened on the Contractors page, on its Pay a freelancer tab, the form filled in: Freelancer's name Linh Tran, Freelancer's email linh@example.com, What they delivered 10 social posts for October, Amount (USDC) 25.00, and a Link to the work. Below it, the result after Set up payment: a message that the link was emailed, the payee link with a Copy link button, and when the link expires.](https://www.vestiarion.xyz/docs/guides/pay-freelancer.png) *One form sets up the payment; the link goes to the freelancer by email and is shown once to copy.* The link takes them through their side in three steps, and afterwards shows them their payment's status, up to a confirmation with the transaction: [Get paid as a freelancer](https://www.vestiarion.xyz/docs/guides/get-paid) is the guide to send them. When they add their address, the members who can confirm addresses get an email. Confirm it on **Counterparties** with **Confirm address**, and the agent pays the milestone within a minute. Until then the milestone is listed under **Needs you** on Contractors, reading "Address to confirm", with a link to Counterparties; before they add an address, it reads "Waiting for an address". In a live workspace nothing is paid before that; a sandbox pays them simulated, at once. The steps below are the same path one at a time, for someone already on file. ## 1. Add the contractor Open **Counterparties** and add the person or studio you pay, with **Role** set to **Contractor** and a **Payment limit (USDC)** at least as large as the milestones you will add. A vendor can be paid for milestones too; a client cannot. The agent pays to the counterparty's Arc address. To have the contractor enter it themselves, open their row on **Counterparties** and choose **Ask for address**, then **Create link**, and send them the link. They need no account. Once they have entered it, choose **Confirm address**: until someone confirms a new address, the agent waits. ## 2. Add the milestone Open **Contractors**, open **New payment**, and choose **Milestone intake**: - **Contractor**: who is paid. - **Amount (USDC)**: what the work is worth. - **Work delivered**: what they did, in a few words. It is the title on the milestone's card. - **Evidence link** (optional): where the work can be seen. A GitHub pull request is checked for you; once your workspace [connects GitHub](https://www.vestiarion.xyz/docs/guides/github), one in a private repository too, and the pull request gets a comment once it is paid. Any other `https://` link, to a design file or a shared folder, is kept on the card for whoever verifies the work. Choose **Add milestone**. The milestone starts pending, and its card says "The agent is waiting for milestone verification." Adding it is signed in the audit log as `create_milestone`. ## 3. Verify the work On **Contractors**, the tiles count what needs you, what waits for verification, what is verified and being paid, and what has been paid. Under **Milestones**, each milestone is one row, in three groups: **Needs you** (held, or refused by code), **In progress** (waiting for verification, or verified) and **Paid and closed** (the latest ten; **Show all** lists every one). Open a row to see its card, and under it the milestone's verification and escrow. How a milestone becomes verified depends on its evidence: - **A GitHub pull request.** The form answers "The agent checks the pull request within a minute, and decides on pay once it is merged." Each cycle asks GitHub whether the pull request was merged. Once it was, the card's **Verified by** reads "merged PR", linked to the pull request, and the ledger records `verify_milestone_github`. - **Anything else.** The form answers "Verify it once the work is delivered, and the agent decides on pay within a minute." When the work is in, open the milestone's row, open the work from the card's **Evidence** link, type what you checked into the box under the card (it reads "Evidence checked or approver note") and choose **Verify manually**. The ledger records `verify_milestone_manual`, with your note and who you are. **Revoke manually** takes it back, until the milestone is paid. ## 4. The agent releases the pay Within a minute of a milestone being verified, the agent decides whether to release it now, and writes its reasoning on the card: who the contractor is, their screening, and their history with the workspace. The decision is signed as `milestone_release` or `milestone_hold`. Code checks every release the model asks for. It refuses one for a contractor screened high risk, or one above the contractor's payment limit, and the card says so. A released milestone's card links its transaction on Arc testnet. A held milestone is decided again once something it was held for changes: the contractor's payment limit, their screening, or the milestone's evidence. Raising the limit on the counterparty brings the agent back within a minute. The ledger records `milestone_reopened` with what changed, followed by the new decision. ### Several milestones at once When a cycle releases two or more milestones, the agent pays them together: one transaction on Arc testnet pays every contractor. Each milestone is still decided and checked on its own, and keeps its own signed `milestone_release` entry. - **One transaction, all or none.** The operating wallet is a Circle smart account, and the batch is one call to its own `executeBatch`. The wallet sends USDC to each contractor in turn, and if any transfer fails, none happens. So the agent sends a batch only when the operating balance covers all of it. Otherwise it pays each milestone alone, as many as the balance allows. - **What the card says.** A milestone paid this way says it was paid in one Arc transaction with the others, for example "paid in one Arc transaction with 2 other milestones". It links the shared transaction. The ledger entry's `execution.batch` holds the batch's key and size. - **Fees.** The transaction's fee is split equally between its milestones. - **What is never batched.** A milestone locked in escrow is released on its own, as is the only milestone released in a cycle. - **If Circle's answer is lost.** The milestone stays verified, and the agent looks the batch up on Circle the next cycle. It never sends the batch's payments again alone while Circle might have it. If Circle never received the batch, each milestone is then paid alone. ## When a milestone is held A held milestone is listed under **Needs you**, and its row says in a few words what it waits for, under its title: for example "Circle did not send it" or "Limit lowered by a screening match". Open the row. Above its card, **What it waits for** says it in a sentence, with a link to where it is settled. ![A held milestone's row opened under Needs you on the Contractors page: Puka Hotel, Clean service, Held Oct 2, 2026, 0.30 USDC, with Circle did not send it under the title. Above the card, What it waits for reads: Circle did not send it: Circle could not prepare the transaction (Circle: ESTIMATION_ERROR). Nothing moved. Pay now sends it again. Below that are the Pay now and Close without paying buttons.](https://www.vestiarion.xyz/docs/guides/held-milestone.png) *Each held row says what it waits for; opened, it offers Pay now and Close without paying.* What it waits for decides what you can do: - **The contractor's screening, limit or address.** For example "A screening match lowered Quoc Duong's limit to 0.25 USDC, below this milestone's 1 USDC." Settle it on **Counterparties**: review the match, raise the limit, or confirm the address. Once the limit or the screening changes, the agent decides the milestone again within a minute. It cannot be paid by hand before then. - **Circle did not send it.** Nothing moved. **Pay now** sends it again, on its own, as a new attempt. - **Not screened yet.** Screening could not run when the contractor was added, so the agent releases nothing to them. Screening runs again at every cycle, and once it gives a verdict the agent decides the milestone again. **Pay now** pays it anyway. - **The agent held it**, by its own reasoning or because paying it would have taken the agent past its spending limit. **Pay now** overrides the hold. Someone other than whoever added the milestone must choose it: "You added this milestone, so someone else must approve paying it." If you are the only member of the workspace who can approve payments, there is nobody else to choose it, so **Pay now** stays enabled for you and the row says "You added this milestone. You are the only person in this workspace who can approve payments, so you can pay it yourself, and the ledger records that you did." The `milestone_approval_paid` entry then ends "(entered and approved by the workspace's only approver)". **Pay now** asks first, naming the amount and who is paid. It then pays the milestone as the agent would, from escrow if it is locked there. It still refuses a contractor screened high risk, a milestone above the contractor's limit, an address no one has confirmed, and an operating balance below the milestone. A transfer that may still settle is never sent twice: **Pay now** only records it. The audit log signs the payment as `milestone_approval_paid`, with who chose it. **Close without paying** asks for a reason. The reason is kept on the milestone and in the audit log, signed as `milestone_closed`. The milestone moves to **Paid and closed**, marked "Closed without paying", and is never paid after: it cannot be verified again, and the agent never decides it. Closing is refused while a transfer for the milestone may still settle, and while its USDC is locked in escrow: refund the hold first. ## Lock a milestone in escrow A live workspace can lock a milestone's USDC in a contract on Arc testnet before the work starts, so the contractor can see the money is set aside for them. - **Set it up once.** On **Contractors**, an owner or admin opens **Milestone escrow** and chooses **Set up escrow**. Setting it up: - creates a deployer wallet; - sends that wallet 0.1 USDC of gas from the operating wallet; - deploys the contract through Circle's Smart Contract Platform. If it is interrupted, choose **Finish setting up**: nothing is deployed twice. - **Lock a milestone.** Open the row of a milestone that is not verified yet; under its card, set **Refundable to this workspace from** and choose **Lock in escrow**. - The contractor needs a confirmed Arc testnet address. The form names the address the hold will pay; that address cannot change afterwards. - The card then reads, for example, "2 USDC locked in escrow for 0x67C8…0504 until 31 Oct 2026", with the transaction that funded it. - **What the contract allows.** Only the workspace's operating wallet can call it, and only two things can happen to a hold, once: - it is released to the contractor's address; - from the refund date, it is refunded to the workspace. Before that date, the money can go nowhere but to the contractor. - **Release.** Once the milestone is verified, the agent pays it by releasing the hold instead of sending a transfer. It holds the milestone for a person instead when: - its amount changed after it was locked; - the contractor's address is no longer the one the hold pays. Refund it from its date, then pay the new address. - **Refund.** From the refund date, a hold that is still on chain shows **Refund from escrow**. The contract was written for Vestiarion and is not audited. Its source, its functions and the address it runs at in production are on [Contracts on Arc testnet](https://www.vestiarion.xyz/docs/contracts). ## 5. Read it in the audit log Open **Audit log** to see the milestone's whole story in order, each entry signed: `create_milestone` when it was added, the verification, and the agent's release decision with its reasoning and its transaction. [Verify an audit export](https://www.vestiarion.xyz/docs/guides/audit-export) shows how someone outside the workspace can check the same entries. --- # Get paid as a freelancer > For the person being paid: add your wallet address through the link a business sent, follow each step, and check the payment on Arc testnet. This guide is for the person being paid. A business that uses Vestiarion can pay you in USDC on Arc testnet for work you delivered. You need no account. You need two things: the link the business sent you, and a wallet address. The page behind the link takes you through three steps and tells you which one you are on: **Your address**, **Confirmation** and **Payment**. ## What you need - **The link.** It comes in an email titled "*Business* wants to pay you *amount* USDC", with an **Add my address** button. A business can also send you the link itself; it starts with `https://www.vestiarion.xyz/payee/`. - **A wallet you control** that can hold USDC on Arc testnet, such as MetaMask. Its address starts with `0x` and has 42 characters. Your address on Arc testnet is the same as your Ethereum address in that wallet. - **Nothing else.** No account, no fee, no password. Vestiarion only needs your address. It never asks for your recovery phrase or private key. If anyone asks you for those, stop. To see Arc testnet in MetaMask, add it as a network: | Field | Value | |---|---| | Network name | Arc Testnet | | RPC URL | `https://rpc.testnet.arc.network` | | Chain ID | `5042002` | | Currency symbol | USDC | | Block explorer | `https://explorer.testnet.arc.io` | ## 1. Add your address Open the link. The page says who wants to pay you, how much, and for what. Under it are the three steps in a line each. Copy your address from your wallet's receive screen rather than typing it. Paste it into **Your wallet address on Arc testnet** and choose **Continue**. If the business pays you on another chain, the field names that chain instead, such as Base Sepolia. ![The payee page at Step 1 of 3. The heading reads Northstar Studio wants to pay you 25.00 USDC. Below it, the payment: 10 social posts for October, 25.00 USDC. Then three numbered steps, the field Your wallet address on Arc testnet with an address pasted in, the Continue button, and a footnote that no account or fee is needed and when the link expires.](https://www.vestiarion.xyz/docs/guides/get-paid-address.png) *Step 1 shows what you are being paid and asks for one thing: your address.* If what you pasted is not an address, the page says "That doesn't look like a wallet address. It starts with 0x and has 42 characters in all." Copy it again, in full. ## 2. Check it, then send it The next screen, **Check your address**, shows the address back in groups of four characters, with the first and last groups in bold. Compare them with your wallet. Then tick all three boxes under **Before you send it, tick all three**: - "It's my own wallet, and I can open it." - "It's not an exchange deposit address." - "The first and last characters match my wallet." It is a checklist, not a choice: **Send my address** works once all three are ticked, and until then the page says "Tick all three boxes to send your address." **Edit address** takes you back to change it. ![Step 1 of 3, Check your address. The address is shown in groups of four characters. Under Before you send it, tick all three, three ticked boxes: It's my own wallet, and I can open it. It's not an exchange deposit address. The first and last characters match my wallet. Below, Edit address and Send my address.](https://www.vestiarion.xyz/docs/guides/get-paid-check.png) *A payment to a wrong address cannot be taken back, so the page asks you to check it first.* After you send it, the link cannot change the address. If you notice a mistake, tell the business at once and ask for a new link: they confirm every new address before paying, so a wrong one can still be replaced before any money moves. ## 3. Wait for the business to confirm The same link now shows Step 2: "Address sent", and that the business confirms it next. It shows your address shortened to its first and last characters. A person at the business checks every new address before any money is sent to it. This protects you as much as them: someone who got hold of your link could not redirect your payment. The business gets an email when your address arrives. ![Step 2 of 3. The heading reads Address sent. Northstar Studio confirms it next. A status box says Waiting for Northstar Studio to confirm, with the address shortened. Below, why a person checks the address, the payment of 25.00 USDC for 10 social posts for October, and a footnote to keep the link.](https://www.vestiarion.xyz/docs/guides/get-paid-confirming.png) *Step 2: nothing for you to do. The page updates by itself.* Keep the link. It keeps showing your payment's status for 30 days after you sent your address. You do not need to reload it: while it waits on someone else, the page updates by itself every 30 seconds. ## 4. Get paid Once the business confirms your address, the page shows Step 3, **Your address is confirmed**, with each payment and its status. Their agent pays a confirmed payment usually within a minute. When every payment is sent, the page becomes your confirmation: "You've been paid", with the amount, and below it who paid you, for what, the address it went to, the network, when it was paid, and the transaction. **View on Arcscan** opens the transaction on Arc testnet's explorer, where anyone can check it. If the business has your email, you are also told by email, from no-reply@vestiarion.xyz, once the payment is confirmed: "*Business* paid you *amount*", what for, the address and the time, and **View the transaction**. For anything about the payment itself, contact the business. ![All done. Paid. The heading reads You've been paid 25.00 USDC. A receipt lists From Northstar Studio, For 10 social posts for October, To the shortened address, Network Arc testnet, Paid on Oct 2, 2026, 09:14 UTC, and the Transaction with a link. Below, the View on Arcscan button and a note on what to do if the money is not in the wallet yet.](https://www.vestiarion.xyz/docs/guides/get-paid-paid.png) *The confirmation: what you were paid, and the transaction that proves it.* ## What each status means | On the page | What it means | What you do | |---|---|---| | Waiting for *business* to confirm | Your address arrived; a person has not confirmed it yet. | Nothing. If it has been a day, remind the business. | | Waiting for *business* to approve the work | The business has not marked the work as delivered yet. | Nothing, or ask the business. | | Being prepared | Everything is ready; the agent pays within about a minute. | Nothing. | | Scheduled for *date* | The business pays on that day, as its terms say. | Nothing. | | With *business* for review | A person at the business looks at the payment before it is sent. The page never says why. | Nothing. Ask the business if you have questions. | | On its way | The transfer was sent and is being confirmed on chain. | Wait a moment. | | Paid | The money was sent to your address. | Check your wallet, or the transaction on Arcscan. | ## Words you will see - **USDC**: a stablecoin issued by Circle, meant to be worth one US dollar. - **Arc testnet**: the test network of Arc, Circle's blockchain for stablecoin payments. Payments there settle in seconds. - **Wallet address**: where money is sent to you, `0x` and 40 letters and numbers. Sharing it is safe; it lets people pay you, not take from you. - **Recovery phrase** and **private key**: what opens your wallet. Never share them, with Vestiarion or anyone else. - **Transaction**: one transfer on the blockchain, with an id (its hash) that starts with `0x`. - **Arcscan**: Arc testnet's public explorer, where anyone can look a transaction up by its hash. - **Confirm**: the business checking that an address really is yours before it pays to it. ## Fees and timing - **Fees.** You pay nothing. The business pays the network fee, which on Arc is less than a cent, in USDC. If you are paid on another chain, the business pays the transfer fee too. - **Confirmation.** It depends on the business: a person has to do it. - **Payment.** After confirmation, the agent pays within about a minute, and a payment on Arc testnet is final within seconds. - **Another chain.** If you are paid on Base Sepolia, Arbitrum Sepolia or Ethereum Sepolia, the money is sent from Arc testnet. It arrives on your chain a few minutes later. The transaction on Arcscan is the sending side. ## Before you send your address - [ ] I copied it from my wallet's receive screen, not typed it. - [ ] It is my own wallet, which I can open, not an exchange deposit address. - [ ] It is for the network the page names, usually Arc testnet. - [ ] Its first and last characters match my wallet. - [ ] Nobody asked me for my recovery phrase or private key. ## If something goes wrong | What you see | Why | What to do | |---|---|---| | That doesn't look like a wallet address. It starts with 0x and has 42 characters in all. | What you pasted is not an address, or it was cut short. | Copy the whole address again from your wallet. | | This link is no longer valid. Ask the business that sent it for a new one. | The link expired before you used it, the business made a newer one, or it is more than 30 days since you used it. | Ask the business for a new link. | | That is already the address *business* has on file. | The business already had this address. | Nothing; it is confirmed the usual way. | | That did not work. Try again in a moment. | Something failed on the way; your address was not saved. | Try again. If it keeps happening, tell the business. | | This page could not load. Try again in a moment. | The page could not read the link's status. | Reload in a minute. | | Stuck on Waiting for *business* to confirm | Nobody at the business has confirmed it yet. | Ask them to confirm it on their Counterparties page. | | Paid, but nothing in your wallet | The wallet is not showing Arc testnet, or not its USDC. | Add Arc testnet with the table above, then check again. The transaction on Arcscan shows where it went. | | You sent a wrong address | The link cannot change it. | Tell the business before they confirm, and ask for a new link. A payment already sent cannot be reversed. | ## After you are paid The confirmation stays on the link for 30 days after you sent your address. To keep a record for longer, keep the transaction link: it stays on Arcscan. If the business pays you again later, it sends a new link, or pays the address it already has. --- # Get the agent's decisions in Telegram > Connect your own Telegram chat to a workspace: the agent's decisions as it makes them, what is safe to spend and waiting, and invoices sent to the bot. Connect your own Telegram chat to a workspace, and the chat tells you what the agent decided, a minute after it decides. You can ask it what is safe to spend and what waits for a person, and send it an invoice to add as a payable. The bot never approves or pays anything: what the agent stopped is still decided in Vestiarion, behind your sign-in. ## 1. Connect your chat Open **Settings** in your workspace. Every member sees a **Telegram** card at the top, under **Notifications**, whatever their role. ![Settings, opening with the Notifications section: the switch Email me when payments need a decision, then a card titled Telegram: get the agent's decisions in your own Telegram chat, ask what is waiting or safe to spend, and send it invoices to add; the bot never approves or pays, that stays here. Below the words, the Connect Telegram button.](https://www.vestiarion.xyz/docs/guides/telegram-connect.png) *The Telegram card in Settings, under Notifications. Each member connects their own chat.* 1. Choose **Connect Telegram**. The card gives you an **Open Telegram** button. The link works once, for 10 minutes. 2. Choose **Open Telegram**. Telegram opens a chat with the Vestiarion bot. Press **Start**. 3. The bot answers that the chat is connected to your workspace, and lists what you can ask. Back in Vestiarion, the card now says which Telegram account is connected, and since when. If the bot answers "This link was already used or has expired.", choose **Connect Telegram** again for a new link. A link connects one chat to your membership of one workspace; the ledger records `telegram_connected` with your Telegram username masked. The bot works only in a private chat with you. Added to a group, it answers "I only work in a private chat" and shows nothing, so a workspace's payments never reach people who are not its members. ## 2. What the chat receives After each cycle, the chat gets one message listing what the agent decided since the last one: what it paid, scheduled, released or received, and what it stopped. Each decision says why, and links to where you see it in Vestiarion. ```text Acme · the agent decided 2 things ✅ Paid Centronex 0.35 USDC · 26 s after it was added. DeepSeek decided, as the written policy would. Checks passed: purchase order and goods, the 2.00 USDC limit, screening. Arc testnet transaction · How it decided ⏸ Held Jiren 3.00 USDC for you. The invoice is above Jiren's 2 USDC payment limit. Decide in Approvals ``` - ✅ marks what was done; **Arc testnet transaction** opens the payment on the explorer. - ⏸ marks what the agent stopped. **Decide in Approvals** opens the payable in Vestiarion, where you approve, reject or return it. - A chat is told only about decisions made after it was connected. A busy cycle's message ends with how many more decisions are in the console. ## 3. Ask it | Ask | What you get | |---|---| | `/today` | Safe to spend today, what is due in the next 30 days, how many payments wait for a person, and what the agent pays next | | `/waiting` | Each payable or milestone waiting for a person, the first sentence of why the agent stopped it, and a link to decide it | | `/ledger` | Whether the signed ledger is intact, checked the way **Verify hash chain** checks it | | `/workspaces` | Your connected workspaces, to switch between them | | `/disconnect` | Stops this workspace's decisions coming to the chat | | `/help` | What the bot does, and what it never does | You can also ask in plain words, in English or Vietnamese: "what is held?", "còn tiêu được bao nhiêu?". A model only picks which of the questions above you asked; the answer is always read from your workspace and written by code, so no figure comes from the model. ## 4. Send it an invoice An owner or admin can send the bot an invoice: a PDF, an `.eml` or `.txt` file of at most 4 MB, or the invoice's text pasted into the chat. The bot reads it the way **From a document** on AP / AR reads one, and shows every field it read, with what to check and the model's note, marked as The model's note: it can be wrong. When it read a counterparty already in the workspace, an amount and a due date, three buttons follow: - **Add, goods received** adds the payable with the goods or services marked received. - **Add, not received yet** adds it without. The agent then asks for the goods to be received before it pays. - **Cancel** answers "Not added." and adds nothing. After **Add**, the bot says the payable was added. The agent usually decides within a minute, and its decision will be sent here, to the same chat. A draft can be added once, within an hour; after that the bot says "This draft was already used or has expired." Send the invoice again to read it anew. The bot does not read photos: it asks you to Send the invoice as a PDF, or paste its text. When it cannot add an invoice from the chat, because no counterparty in the workspace matches its vendor or it has no amount or due date, it says what is missing and links to AP / AR, where you can add it by hand. An approver or a viewer who sends an invoice is told "Only an owner or admin can add invoices." The invoice is added exactly as one added on AP / AR: the ledger's `create_invoice` entry names you, and adds `via: "telegram"` and the hash of the document. The document itself is not stored. Its USDC moves on Arc testnet only when the agent pays it, under every check it applies to any payable. ## 5. More than one workspace Connect each workspace from its own **Settings**. One chat can hold several: decisions come from all of them, each message headed with its workspace's name. Commands and invoices go to one of them at a time. Send `/workspaces` to see them as buttons, the active one marked ✓, and tap another to switch; the bot answers "Commands and invoices now go to" that workspace. ## 6. Disconnect Choose **Disconnect** on the **Telegram** card, or send `/disconnect` in the chat. Either stops the workspace's decisions coming to the chat, and the ledger records `telegram_disconnected`. If you block the bot in Telegram, Vestiarion disconnects the chat at its next message. If someone removes you from the workspace, your chat's connection goes with your membership. ## For the person who runs the deployment The bot is one Telegram bot for the whole deployment, made with @BotFather. Set `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET` and `TELEGRAM_BOT_USERNAME`, then run `npm run telegram:setup -- https://` once to register the webhook and the command menu. Without all three, the **Telegram** card does not show and the webhook answers 404. The webhook accepts only requests that carry the secret Telegram was given. --- # Add invoices by email > Turn on a workspace address, forward suppliers' invoices to it, and add each one from AP / AR with one press; nothing arrives as a payable by itself. Forward an invoice to your workspace's address, and Vestiarion reads it the way **From a document** reads one. It waits on **AP / AR** under **From email** until an owner or admin adds it as a payable with one press, or dismisses it. Nothing that arrives by email is added, or paid, by itself: once it is added, the agent decides it as any payable, and pays on Arc testnet under every check it applies. ## 1. Turn it on An owner or admin opens **Settings** and finds **Invoices by email**. ![The Invoices by email section of Settings, turned on: the workspace's address, invoices- followed by a code at the inbound domain, with a Copy the address button; a note that anyone who knows the address can send to it and that a leaked address can be replaced; and the New address and Turn off buttons.](https://www.vestiarion.xyz/docs/guides/email-inbox-settings.png) *Invoices by email in Settings, as an owner or admin sees it once it is on.* 1. Choose **Turn on**. The section shows the workspace's address, `invoices-…@…`, with **Copy the address**. 2. Give the address to your suppliers, or forward their emails to it yourself. Anyone who knows the address can send to it. If it leaks, choose **New address**: the old one stops at once. **Turn off** stops it; the emails already in stay on AP / AR. The ledger records `invoice_inbox_on`, `invoice_inbox_changed` and `invoice_inbox_off`, never the address itself. Other members see that it is on, and that An owner or admin has the address. ## 2. What happens to an email Vestiarion reads the first PDF, `.eml` or `.txt` file attached, of at most 4 MB, or the email's own text when nothing is attached. Within a minute it is on **AP / AR**, under **From email**: ![AP / AR with a From email section: an email titled Invoice INV-2207 from Northwind Billing, read as Northwind Hosting, 200.00 USDC due Oct 31 with purchase order PO-1042; a line saying SPF, DKIM and DMARC pass for this sender and that it is the billing email Northwind Hosting has on file; the Add, goods received, Add, not received yet and Dismiss buttons; and below them a folded Edit and add section.](https://www.vestiarion.xyz/docs/guides/email-inbox-ap.png) *An invoice that arrived by email, read and waiting for a person on AP / AR.* - An email ready to add shows the counterparty it matched, the amount, the due date, the purchase order, and what to check. - **Cannot be added as it was read** says why: no counterparty in the workspace matches the vendor, or no amount or due date could be read. A vendor that matches none shows by its name, marked "not in Counterparties". "Fix what is missing with Finish and add." - **Could not be read** says why: a scan with no text, a file over 4 MB, or nothing to read. A scanned PDF says "Its PDF has no text to read; it may be a scan. Ask the sender for the invoice as a PDF with text, or type it in with Finish and add." - An invoice sent as a photo or picture is not read. Its email says so first: "Its invoice is attached as an image (…), which Vestiarion cannot read yet. Ask the sender for the PDF, or type it in with Finish and add." The invoice can be laid out any way and written in any language: the model reads it. Amounts written with a decimal comma, such as 3,50 USDC or 1.200,00 EUR, are read as 3.50 and 1200.00. When the due date comes from a date written in numbers whose day and month could swap, such as 03/11/2026, a note says which day was taken and which it can also mean, so you can "Check it against the invoice." Each says whether the sender passed SPF, DKIM and DMARC, and whether it is the billing email the counterparty has on file. That is a check for you, never permission: Vestiarion adds nothing by itself. If the workspace has Slack, its channel is told a new invoice arrived by email, with a **Review in AP / AR** link. The ledger records `invoice_email_received`, with the sender's address mostly hidden. ## 3. Add it, or dismiss it An owner or admin chooses: - **Add, goods received** or **Add, not received yet**: the payable is added as one added on AP / AR is, by you. The ledger's `create_invoice` entry names you, and adds `via: "email"`, the email's id in the inbox and the hash of the document. The agent usually decides within a minute. - **Edit and add**, on an email ready to add, or **Finish and add**, on one that cannot be added as it was read or could not be read: the invoice form opens with what was read. Choose the counterparty, fix the amount or the due date, tick **Goods or services received** if you have them, and choose **Add invoice**. If you type over an amount that was read, the form names it, for instance "The invoice was read as 1.20: check this amount before you add it." It is always a payable. The `create_invoice` entry names the email as above, and lists the fields you changed from what was read. An amount typed wrong can't be edited afterwards: **Reject** the payable while it waits, and add it again. - **Dismiss**: the email leaves the list, and the ledger records `invoice_email_dismissed`. An email is added once. A second press is told "This email was already decided, or cannot be added as it was read." ## What is kept The sender, the subject and what was read. The document itself is not kept, only its hash, as with From a document. Resend receives the email on Vestiarion's behalf. ## For the person who runs the deployment In Resend, the **Receiving** tab of **Emails** gives a `.resend.app` domain that receives at any address, with no DNS to set; or turn on receiving for a subdomain and add Resend's MX record. Under **Webhooks**, add `https:///api/email/inbound` for the `email.received` event and copy its signing secret. Then set `INBOUND_EMAIL_DOMAIN`, `RESEND_INBOUND_WEBHOOK_SECRET`, and `RESEND_RECEIVING_API_KEY` when the key that sends email cannot read received ones. Without them, the section does not show and the route answers 404; every request to it is checked against the signing secret. --- # Get the agent's decisions in Slack > Connect a workspace to a Slack channel: the agent's decisions as it makes them, /vestiarion for what is safe to spend and waiting, and stopped payments decided from Slack when an owner allows it. Connect your workspace to Slack, and a channel your team picks gets what the agent decided, a minute after it decides. Each member connects their own Slack account to ask what is safe to spend or what waits, and to pause the agent. An owner or admin can add an invoice from a message in Slack. When an owner allows it, a payment the agent stopped can be approved and paid, rejected, or returned to the agent from its message in Slack, under every check Vestiarion makes in the console. ## 1. Connect Slack An owner or admin connects Slack, once per workspace. Open **Settings** and find the **Slack** section. ![The Slack section of Settings. Its first rows read Slack workspace: Acme HQ, Decisions go to: #finance, and Connected since: Oct 3. Under Your Slack account, the account is connected and a Disconnect my account button; under Deciding payments from Slack, payments up to 5 USDC may be decided from Slack, with the Limit (USDC) field and a Save button, dimmed until the amount changes; at the bottom, Reconnect Slack and Remove Slack.](https://www.vestiarion.xyz/docs/guides/slack-settings.png) *The Slack section of Settings, as an owner sees it once Slack is connected and deciding from Slack is allowed up to 5 USDC.* 1. Choose **Add to Slack**. Slack opens and asks you to pick the channel the agent's decisions go to. A private channel your finance people are in is a good choice: everyone in it sees the payments. 2. Choose **Allow**. Slack sends you back to Settings, which says "Slack is connected. The agent's decisions now go to the channel you picked." 3. The section now names the Slack workspace and the channel. The ledger records `slack_installed`, and your own Slack account is connected already. To pick another channel later, an owner or admin chooses **Reconnect Slack** in the same section; members stay connected, and the limit stays. One Slack workspace serves one Vestiarion workspace. If Settings says "That Slack workspace is already connected to another Vestiarion workspace.", remove it from that one first. Anyone else sees "An owner or admin connects Slack." instead of the button. ## 2. Connect your own Slack account The channel's messages reach everyone in the channel. To ask Vestiarion something, or to press a button on a message, each member connects their own Slack account to their own membership: 1. In Slack, type `/vestiarion connect`. Only you see the answer: a link that works once, for 10 minutes, and only for you. 2. Open it. Sign in to Vestiarion if you are not signed in. The page, **Connect your Slack account**, says which Slack account will act as you, in which workspace, and with which role. 3. Choose **Connect my Slack account**. The page answers "Connected. Back in Slack, try /vestiarion today." and the ledger records `slack_member_connected`. A Slack account acts as you only after you confirm it on that page, signed in. Connect only an account that is yours. ## 3. What the channel receives After each cycle, the channel gets one message listing what the agent decided since the last one: what it paid, scheduled, released or received, and what it stopped, each with why, and links. ```text Acme · the agent decided 2 things Paid Centronex 0.35 USDC · 26 s after it was added. DeepSeek decided, as the written policy would. Checks passed: purchase order and goods, the 2.00 USDC limit, screening. [Arc testnet transaction] [How it decided] Held Jiren 3.00 USDC for you. The invoice is above Jiren's 2 USDC payment limit. [Decide in Approvals] ``` - **Arc testnet transaction** opens the payment on the explorer. - **Decide in Approvals** opens the stopped payable in Vestiarion. While deciding from Slack is off, the default, that is where it is decided. - The channel is told only about decisions made after Slack was connected. A busy cycle's message ends with how many more decisions are in the console. ## 4. Ask it | Type | What you get, seen by you alone | |---|---| | `/vestiarion today` | Safe to spend today, what is due in the next 30 days, how many payments wait for a person, and what the agent pays next | | `/vestiarion waiting` | Each payable or milestone waiting for a person, why the agent stopped it, and where to decide it | | `/vestiarion ledger` | Whether the signed ledger is intact, checked the way **Verify hash chain** checks it | | `/vestiarion pause [reason]` | Stops the agent, for an owner, admin or approver. The channel where you typed it sees that you paused it. Resuming stays in Vestiarion | | `/vestiarion disconnect` | Disconnects your Slack account | | `/vestiarion help` | What it does | Every answer is read from your workspace and written by code. ## 5. Decide a payment from Slack Deciding payments from Slack is off until an owner allows it. In the **Slack** section, under **Deciding payments from Slack**, an owner fills in **Limit (USDC)**, the most a payment approved from Slack may be, and saves. Leaving it empty turns it off again; while it is off, the section says "Deciding payments from Slack is off" and a stopped payable's message only links to Approvals. Each change is recorded as `slack_decisions_limit_changed`. Once it is on, a payable the agent stopped gets buttons under its message: ```text Held Jiren 3.00 USDC for you. The invoice is above Jiren's 2 USDC payment limit. [Approve and pay] [Reject] [Return to the agent] [Decide in Approvals] ``` - **Approve and pay**, when the payable is in USDC, its payee is paid on Arc testnet, its amount is within the limit, and its payee's address is confirmed. Slack asks you first, in a box titled **Approve and pay?** that names the amount, the payee and the address, shortened to its first and last four characters. - **Reject**, after Slack asks you to confirm it. - **Return to the agent**, which hands it back for the agent to decide again, usually within a minute. The limit is for Approve and pay alone: Reject and Return work at any amount. When Approve and pay is not offered, the message says why under the buttons, after "Approve it in Vestiarion:", and you decide it in the console. A button acts as you, with your role in the workspace as it is when you press it, and runs the same checks as **Approve and pay** in Approvals: you cannot approve a payable you entered unless you are the workspace's only approver, a payee screened high risk is refused, the operating wallet must hold the amount, and a payable is decided once, whoever presses first. Above the workspace's figure for two approvals, its rules apply instead: whoever entered a payable gives one of its two approvals only when no one else can, and the funds are checked by the approval that pays it. Slack adds two of its own: a button works only on the payable as its message showed it, and never confirms a changed address. If the payable was decided, or the agent decided it again, since the message was posted, you are told "This payable changed after this message was posted. Open Vestiarion to see it as it is now." Approve and pay is also refused when the payee's address is no longer the one the message was posted with: a new address is confirmed on Counterparties, in Vestiarion. A button older than 7 days answers "This button no longer works". Someone who has not connected their Slack account is told "Connect your Slack account to Vestiarion first", and nothing is decided. What you decide is rewritten into the message for the channel: "Approved and paid by" you, with the Arc testnet transaction, or "Rejected by", or "Returned to the agent by". Above the workspace's figure for two approvals, your approval pays nothing yet: the message says you approved it, then "One more approval, by another person, pays it.", and keeps its buttons for a second person, whose approval pays it. A refusal is shown to you alone. In the ledger, the decision is the same `approval_paid`, `approval_rejected` or `approval_returned` entry the console writes, with `via: "slack"` and the id of your Slack link. ## 6. Add an invoice from Slack An owner or admin can add a payable from an invoice in Slack: a PDF, an `.eml` or `.txt` file of at most 4 MB attached to a message, or the invoice's text in the message itself. 1. On the message, open **More actions** and choose **Add invoice**. 2. Vestiarion reads it the way **From a document** on AP / AR reads one, and answers you alone with every field it read, what to check, and The model's note: when there is one. 3. Choose **Add, goods received** or **Add, not received yet** to add it, or **Cancel**, which answers "Not added." The payable is added exactly as one added on AP / AR: the ledger's `create_invoice` entry names you, and adds `via: "slack"`, the id of your Slack link and the hash of the document. The document itself is not kept. The agent usually decides within a minute, and its decision is posted to the channel like any other. A draft can be added once, within an hour; after that you are told "This draft was already used or has expired." When the invoice's vendor matches no counterparty in the workspace, or it has no amount or due date, Vestiarion says what is missing and links to AP / AR, where you can add it by hand. An approver or a viewer is told "Only an owner or admin can add invoices." Vestiarion reads only the message you chose it on, and Slack lets it open a file only in a channel it is in: for a file shared anywhere else, type `/invite @Vestiarion` in that channel first. If Slack was connected before this was possible, Vestiarion answers that it cannot open files in this Slack yet: an owner or admin chooses **Reconnect Slack** once, and Slack asks them to allow it. ## 7. Disconnect - Your own account: type `/vestiarion disconnect` in Slack, or choose **Disconnect my account** in the **Slack** section. The ledger records `slack_member_disconnected`. - The whole workspace: an owner or admin chooses **Remove Slack**. Vestiarion removes the app from the Slack workspace, and every member's Slack link goes with it. Removing the app in Slack does the same. The ledger records `slack_uninstalled`. - If someone is removed from the workspace, their Slack link goes with their membership. ## What Slack receives Workspace and counterparty names, amounts, the agent's reasons, links to Vestiarion and to the Arc testnet explorer. A wallet address only shortened to its first and last four characters; never an email, a key or a full address. The app reads no message in your Slack but the one someone chooses **Add invoice** on: it asks for three permissions, to answer `/vestiarion`, to post to the channel you picked, and to read a file someone chooses. ## For the person who runs the deployment Create the Slack app from `integrations/slack/manifest.yaml` (api.slack.com, **Create New App**, **From a manifest**), and set `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET` and `SLACK_SIGNING_SECRET`. Without all three, the **Slack** section does not show and the Slack routes answer 404; every request from Slack is checked against the signing secret. Slack checks the events URL when it is saved, so add **Event Subscriptions** once the deployment serves it. To let other Slack workspaces connect, turn on public distribution under **Manage Distribution**. An app created before **Add invoice** existed needs the manifest saved again on its **App Manifest** page, and each workspace connected again with **Reconnect Slack**. --- # Add invoices from your own system > Create a read-and-write API key, add a counterparty and an invoice through the API, confirm the address, and follow the agent's decision. Your invoices may already live in another system: an accounting tool, a billing script, a job that pays for merged work. With a read-and-write API key, that system adds counterparties and invoices to your workspace itself. The agent then decides each payable as it decides one typed into the console, with every guardrail, and pays on Arc testnet. A key adds records. It never approves or pays, and an address it adds waits for a person in the workspace to confirm it, so a key that leaks cannot point the agent's payments at a new address. ## 1. Create a read-and-write key An owner or an admin opens **Settings** and, under **API keys**, chooses **Create key**. Name the key after the system that will use it, tick **Can also add records**, and create it. The dialog shows the key once: "Copy this key now. It will not be shown again." In the list of keys, this one shows **Read and write**, and every other key **Read only**. Keep the key in an environment variable, `VESTIARION_API_KEY` below, as [Authentication](https://www.vestiarion.xyz/docs/get-started/authentication) explains. The key works for as long as the person who created it is a member of the workspace. Create it as someone who will stay: if they leave, or are removed, the key stops working, and the system stops adding records. ## 2. Add the counterparty [Add a counterparty](https://www.vestiarion.xyz/docs/api/create-counterparty) with `POST /api/v1/counterparties`: ```bash curl https://www.vestiarion.xyz/api/v1/counterparties \ -X POST \ -H "Authorization: Bearer $VESTIARION_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: crm-vendor-1042" \ -d '{"name":"Quill Studio","role":"vendor","address":"0x5b2d8c1f0e7a4936b8d1c0e2f3a4b5c6d7e8f901","jurisdiction":"SG","paymentLimit":"500"}' ``` The answer is `201`, with the counterparty shaped as [List counterparties](https://www.vestiarion.xyz/docs/api/list-counterparties) returns it. It is screened as it is added, so `riskLevel` already holds the screening's verdict. Keep its `id`: the invoice names it. A vendor or a contractor needs a `paymentLimit`, the most the agent pays it in one payment. A client, who pays you, does not. A body that does not validate answers `400 invalid_request`, naming the field, and adds nothing: fix it and send it again. ## 3. Confirm the address in the console The address you sent waits for a person. On **Counterparties**, the counterparty's card shows the day it changed and "not yet confirmed". Until someone confirms it, the agent holds every payment to it: "held for a person to approve". Check the address with the counterparty through a channel you already trust, then choose **Confirm address**. An owner, an admin or an approver can confirm it. A system that sends addresses is the easiest place to change one, and this step is what keeps a changed address from receiving a payment unseen. An invoice your system adds before then is not lost: the agent holds it, and once the address is confirmed it goes back to the agent, which decides it again with every check, in the cycle the confirmation starts. Its `invoice_reopened` entry says why: "the counterparty's new address has since been confirmed". ## 4. Add the invoice [Add an invoice](https://www.vestiarion.xyz/docs/api/create-invoice) with `POST /api/v1/invoices`, naming the counterparty by its `id`: ```bash curl https://www.vestiarion.xyz/api/v1/invoices \ -X POST \ -H "Authorization: Bearer $VESTIARION_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: billing-inv-2026-0042" \ -d '{"counterpartyId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","amount":"420.00","dueDate":"2026-10-31","poReference":"PO-4012","goodsReceived":true}' ``` The answer is `201`, with the invoice in `status: "pending"`. A payable is now the agent's to decide, usually within a minute, as one typed in: - With a `poReference` and `goodsReceived: true`, the three-way match can complete. Without `goodsReceived: true`, or without a `poReference` for a counterparty that needs one, the agent asks for the missing detail instead of paying, and code refuses a payment either way. Every counterparty needs a purchase order unless an owner or admin marked it as paid without them on **Counterparties**. - A payment over the counterparty's limit, or to an address no one has confirmed, is held for a person. One to a high-risk counterparty, or one that repeats an invoice already paid, is flagged. - The workspace's spending limits, and its contract on Arc testnet, still bound every payment. The ledger records the invoice as `create_invoice`, with `via: "api"` and the key's id. It is the key's issuer's invoice: if the agent holds it, the issuer cannot approve it, unless they are the workspace's only approver. ## 5. Follow the decision Read the invoice back with [List invoices](https://www.vestiarion.xyz/docs/api/list-invoices), filtered by `counterpartyId`. Once the agent has decided, `status` says what it did and `agentReasoning` says why. A payment that settled on Arc testnet carries its `txHash`. To be told rather than to ask, [webhooks](https://www.vestiarion.xyz/docs/webhooks) push every ledger entry, the agent's decision included, as `ledger.appended`. ## Retrying safely A network can fail between a request and its answer. Send an `Idempotency-Key` with every write, unique to the record, such as its id in your own system, and when no answer comes, send the same request with the same key: - A repeat with the same body within 24 hours gets the first answer back, with `Idempotent-Replayed: true`, and adds nothing. - The same key with a different body answers `409 conflict`. So does a repeat while the first request is still being handled: wait a moment and send it again. - A `5xx` answer is not kept, so a repeat runs the request again. Neither is a request that never finished, because it timed out or its connection dropped: after 10 minutes its key is free again. - A body that fails validation is not kept either: fix it, and send it again with the same key. Writes are limited to 30 a minute per key. Over that, the answer is `429 rate_limited`, with `Retry-After`. ## What a key cannot do - Approve, reject or pay a payment. That is the agent's decision, and a person's when the agent holds one. - Confirm an address. - Change or remove a record. The API adds counterparties, invoices, milestones and payee links, and nothing else. The same two operations are [MCP tools](https://www.vestiarion.xyz/docs/ai-integration/mcp), `create_counterparty` and `create_invoice`, so an AI agent connected with a read-and-write key can add records under the same rules. To pay contributors for merged pull requests, see [Pay for merged pull requests](https://www.vestiarion.xyz/docs/guides/api-milestones). --- # Pay for merged pull requests > Add a contractor, a payee link and a milestone through the API, and the agent pays once the pull request is merged. With a GitHub Actions workflow. Pay a contributor when their pull request is merged, from your own system: a CI job, a bounty board, a script. Through the API, that system adds the contributor as a contractor and sends them a link to add their address. It then adds a milestone whose evidence is the pull request. Vestiarion checks the pull request, verifies the milestone once it is merged, and the agent pays it on Arc testnet with every guardrail. A key adds records. It never verifies work, approves or pays. Every address waits for a person in the workspace to confirm it, so a key that leaks cannot point the agent's payments anywhere new. ## 1. Create a read-and-write key An owner or an admin opens **Settings** and, under **API keys**, chooses **Create key**, ticks **Can also add records**, and creates it. In the list of keys, it shows **Read and write**. [Add invoices from your own system](https://www.vestiarion.xyz/docs/guides/api-invoices#1-create-a-read-and-write-key) walks through this step. The key adds records as the person who created it, for as long as they can. If they leave the workspace, the key is revoked. If an owner moves them to approver or viewer, the key still reads, and each write answers `403 forbidden`: "This key's issuer can no longer add records in this workspace." ## 2. Add the contributor [Add a counterparty](https://www.vestiarion.xyz/docs/api/create-counterparty) with `role: "contractor"`. Give it no address: the contributor adds their own in the next step. Its `paymentLimit` is the most the agent pays them in one payment, so make it at least as large as a bounty: ```bash curl https://www.vestiarion.xyz/api/v1/counterparties \ -X POST \ -H "Authorization: Bearer $VESTIARION_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: github-user-octocat" \ -d '{"name":"Mona Octocat","role":"contractor","paymentLimit":"500","noticeEmail":"mona@example.com"}' ``` Keep the answer's `id`. With a `noticeEmail`, Vestiarion emails the contributor once a payment to them is confirmed. ## 3. Ask for their address [Create a payee link](https://www.vestiarion.xyz/docs/api/create-payee-link) for the contractor: ```bash curl https://www.vestiarion.xyz/api/v1/payee-links \ -X POST \ -H "Authorization: Bearer $VESTIARION_API_KEY" \ -H "Content-Type: application/json" \ -d '{"counterpartyId":"3d6f8a21-9c4b-4f0e-8b7a-5e2c1d9f6a48"}' ``` The answer's `url` is a one-time page where the contributor adds the address they want to be paid at. They need no account, and [Get paid as a freelancer](https://www.vestiarion.xyz/docs/guides/get-paid) is the guide to send them with it. Send the link yourself, by email or in the pull request's thread: - **It is shown once.** Vestiarion keeps only its hash, so no request can give the same link back. - **It expires.** It works once, and expires after 7 days. - **A new one replaces it.** Asking again makes a new link, and the unused one stops working. That is also why this operation takes no `Idempotency-Key`. The ledger records it as `payee_link_created`, with `via: "api"`, and never the link itself. Once the contributor has added an address, the members who can confirm addresses get an email. On **Counterparties**, the contractor's card shows the address as "not yet confirmed". Check it with the contributor through a channel you already trust, then choose **Confirm address**. Until someone does, the agent pays nothing to it. ## 4. Add the milestone When a pull request is to be paid for, [add a milestone](https://www.vestiarion.xyz/docs/api/create-milestone) whose `verificationSource` is the pull request: ```bash curl https://www.vestiarion.xyz/api/v1/milestones \ -X POST \ -H "Authorization: Bearer $VESTIARION_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: pr-acme-app-42" \ -d '{"contractorId":"3d6f8a21-9c4b-4f0e-8b7a-5e2c1d9f6a48","title":"CSV export for EURC","amount":"150.00","verificationSource":"https://github.com/acme/app/pull/42"}' ``` The answer is `201`, with the milestone in `status: "pending"`, as [List milestones](https://www.vestiarion.xyz/docs/api/list-milestones) returns it. On **Contractors**, its card says "The agent is waiting for milestone verification." The ledger records it as `create_milestone`, with `via: "api"` and the key's id. The milestone is the key's issuer's. If the agent holds it on its own judgment, someone other than the issuer must choose **Pay now**, unless the issuer is the workspace's only approver. Add the milestone before the pull request is merged, or after: either way it is paid only once it is merged. Use the pull request as the `Idempotency-Key`, so that a job that runs twice adds it once. ## 5. Merge, and the agent pays Each cycle asks GitHub whether the pull request was merged. 1. **Merged.** The milestone is verified. On its card, **Verified by** reads "merged PR", and the ledger records `verify_milestone_github`. 2. **The agent decides.** In the same cycle, it decides the payment with every guardrail: - the contractor's screening and payment limit; - a confirmed address; - the workspace's spending limits, and its contract on Arc testnet. 3. **Paid.** Read the milestone back with [List milestones](https://www.vestiarion.xyz/docs/api/list-milestones), filtered by `contractorId`. `status` says what the agent did and `agentReasoning` says why, and a payment that settled carries its `txHash`. [Webhooks](https://www.vestiarion.xyz/docs/webhooks) push each of these entries as it is written. If the contributor has not yet added an address, the verified milestone waits on Contractors, reading "Waiting for an address". Once they have added one that nobody has confirmed, it reads "Address to confirm". Either way, the agent pays once the address is confirmed. Vestiarion reads the pull request with its own GitHub access, so the repository must be public, unless your workspace [connected GitHub](https://www.vestiarion.xyz/docs/guides/github) with the app installed on it: then private repositories verify too, and the pull request gets a comment saying it was paid. For work that is not a pull request, any other `https` link is kept as evidence. A person checks the work and chooses **Verify manually** on the milestone's card. ## From a GitHub Actions workflow This workflow adds a milestone when a pull request labelled `bounty` is merged. Keep the key as the repository secret `VESTIARION_API_KEY`, and the contractor's `id` as the variable `CONTRACTOR_ID`: ```yaml name: Pay merged bounties on: pull_request: types: [closed] jobs: milestone: if: github.event.pull_request.merged && contains(github.event.pull_request.labels.*.name, 'bounty') runs-on: ubuntu-latest steps: - name: Add the milestone to Vestiarion env: VESTIARION_API_KEY: ${{ secrets.VESTIARION_API_KEY }} CONTRACTOR_ID: ${{ vars.CONTRACTOR_ID }} PR_TITLE: ${{ github.event.pull_request.title }} PR_URL: ${{ github.event.pull_request.html_url }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | body=$(jq -n --arg c "$CONTRACTOR_ID" --arg t "$PR_TITLE" --arg u "$PR_URL" \ '{contractorId: $c, title: ($t | .[0:160]), amount: "150.00", verificationSource: $u}') curl --fail-with-body https://www.vestiarion.xyz/api/v1/milestones \ -X POST \ -H "Authorization: Bearer $VESTIARION_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: pr-$GITHUB_REPOSITORY-$PR_NUMBER" \ -d "$body" ``` The pull request's title and link reach the script as environment variables, never pasted into it, so a title cannot run as a command. With the [TypeScript SDK](https://www.vestiarion.xyz/docs/get-started/sdk), the same call is `vestiarion.milestones.create(input, { idempotencyKey })`. ## What a key cannot do - Verify a milestone. GitHub does, once the pull request is merged, or a person does. - Approve, reject or pay a payment. That is the agent's decision, and a person's when the agent holds one. - Confirm an address. - Change or remove a record. The API adds counterparties, invoices, milestones and payee links, and nothing else. The same operations are [MCP tools](https://www.vestiarion.xyz/docs/ai-integration/mcp), `create_milestone` and `create_payee_link`, so an AI agent connected with a read-and-write key can add them under the same rules. --- # Show payments on GitHub > Connect GitHub so a milestone paid for a pull request gets a comment on it, pull requests in private repositories verify, and a maintainer attaches a bounty with a comment. Connect GitHub, and every milestone paid for a pull request says so on that pull request. Once the payment is confirmed, the Vestiarion app comments there with the amount and the Arc testnet transaction. Contributors, maintainers and anyone reading the pull request see it was paid, where the work was done. The same connection lets the agent verify pull requests in your private repositories, and lets a maintainer attach a bounty with a comment, without leaving GitHub. The app reads pull requests and their comments, and writes comments, on the repositories you choose. It never changes code, never merges and never approves a payment: the agent decides each payment with every guardrail, as before. ## 1. Connect GitHub An owner or an admin opens **Settings**. Under **GitHub**, they choose **Connect GitHub**. GitHub asks two things: 1. **Where to install the app.** Your own account or an organization, and all of its repositories or only the ones you select. Choose the repositories your contributors work in. 2. **To confirm who you are.** Vestiarion asks GitHub which installations of the app your account can reach, and connects this one only if GitHub lists it. A forged or copied link cannot connect someone else's repositories. Back in Settings, the panel says "GitHub is connected. A milestone paid for a pull request in its repositories now gets a comment on it." It lists the account, whether it covers all repositories or selected ones, and when it was connected. The ledger records `github_connected`. To add repositories later, change the installation on GitHub. To connect a second GitHub account or organization, choose **Connect another account**. Other members see the connected accounts; "An owner or admin connects GitHub." If you are a member of a GitHub organization who may not install apps there, GitHub asks its owners to approve the app instead. Connect again once they have. ## 2. Pay for pull requests Add milestones as you do already, with the pull request as their evidence: from **Contractors**, or [through the API](https://www.vestiarion.xyz/docs/guides/api-milestones). Each cycle asks GitHub whether the pull request was merged. Once it was, the milestone is verified (`verify_milestone_github`), and the agent decides the payment. In a repository the app is installed on, the merge itself starts that cycle, so the payment is decided within a minute of the merge rather than at the next scheduled cycle. In a repository the app is installed on, this works for private repositories too. The check reads them with the installation's own access. Any other pull request is read as before, so it must be public. ## 3. Attach a bounty from a comment Anyone who can write to the repository can attach a bounty to a pull request from GitHub. On a line of its own, they comment: ```text /bounty 25 ``` The amount is in USDC, with at most 6 decimal places; `/bounty 25 USDC` works too. The app replies on the pull request with the bounty, and asks its author where to be paid. Vestiarion adds two things, as if you had added them on **Contractors**: - **A contractor** for the pull request's author, named after their GitHub login with "(GitHub)", whose payment limit is the bounty's amount. The same GitHub account stays the same contractor for every later bounty in the workspace. - **A milestone** for the pull request, named "PR #" with its number and title, with the pull request as its evidence. It is verified once the pull request is merged, as in [Pay for pull requests](#2-pay-for-pull-requests). They are added under the name of the person who connected GitHub. The audit log records `github_bounty_attached` with the login of the person who commented. If the person who connected GitHub has left the workspace, or can no longer add records, the app says so on the pull request, and someone connects GitHub again in Settings. The pull request's author then says where to be paid: ```text /payto 0xYourArcAddress ``` Only the pull request's author can do this. The address waits for an owner, admin or approver to confirm it on **Counterparties**, as an address a payee sends through a payee link does, and they are emailed that it arrived. The agent pays nothing to it before then. A contributor paid before is paid to the address on file, and the reply says so. Once the pull request is merged and the address confirmed, the agent decides the payment with every guardrail: screening, the contractor's payment limit, and the daily spending limit, with its contract on Arc. The app then comments that it was paid, as below. When it attaches nothing, the app says why: | The app replies | Why | | --- | --- | | Only someone who can write to this repository can attach a bounty. | GitHub says the person who commented cannot write to the repository. | | This pull request already has a bounty | A pull request has one bounty. Change its amount on its milestone in Vestiarion. | | A bounty pays a person, and this pull request was opened by a bot. | The pull request's author is a bot account. | | This pull request was closed without being merged | There is no work to pay for. A merged pull request can still be given a bounty: it is paid at the agent's next run. | | This repository is connected to more than one Vestiarion workspace | Two workspaces connected the same GitHub account. Add the milestone in Vestiarion instead. | ## 4. The comment Once the payment is confirmed on Arc testnet, the next cycle comments on the pull request. The comment starts with "Paid:", then the amount, "on Arc testnet", "for this pull request, by" your workspace's name, and the transaction's link. The ledger records `pull_request_commented`, with the comment's link. - **Who is named.** Only your workspace. The comment never names the payee; their address is visible on chain through the transaction's link, as for any payment on Arc testnet. - **Where.** Only in a repository whose installation your workspace connected. A pull request in any other repository is paid as usual and gets no comment. - **When.** Once per payment, confirmed within the last three days, and after you connected GitHub: connecting never comments on pull requests paid before. - **If GitHub refuses.** The comment is tried again at each cycle within those three days. This happens when the app has been uninstalled, for example. ## 5. Disconnect In Settings, **Disconnect** removes the account from this workspace. The ledger records `github_disconnected`. From then on, no comment is posted there, and its private pull requests are no longer read. GitHub keeps the app installed until you uninstall it, on the account's GitHub settings, under Applications. Uninstalling is what takes the app's access away. --- # Verify an audit export > Download a workspace's signed ledger and check it with a standalone verifier. Every decision a workspace's agent or its people make, including a payment on Arc testnet, is appended to the workspace's ledger, hash-linked to the entry before it and signed with Ed25519. The Audit log page checks that chain for you. An export lets someone else check it, with their own copy and without Vestiarion: an auditor, your accountant, or a counterparty. ## 1. Download the ledger Open **Audit log**. Next to the verification result, under **Download**, choose: - **Signed JSON**: the whole chain, oldest entry first, with the public keys that vouch for it. This is the file that verifies. - **CSV**: the same entries as a spreadsheet, one row per entry. It is for reading, not for checking: a spreadsheet may change the text it opens, and cells that would run as formulas are prefixed with `'`. Every member of the workspace can export, viewers included. Each export is itself recorded in the ledger, as `ledger_exported`, with who exported it, the format, and how many entries it held. ## 2. What the file holds The JSON file has the format `vestiarion-ledger-export/1`: - `workspace`: its slug and name; - `head`: the last entry in the file, by `seq` and `hash`; - `keys`: every public key the workspace accepts signatures from, each with its key id; - `verification`: what Vestiarion's own check said when the file was made; - `entries`: every entry, exactly as stored: `seq`, `id`, `ts`, `actor`, `domain`, `action`, `summary`, `detail`, `body_hash`, `signature`, `prev_hash`, `hash` and `signing_key_id`. An entry's `body_hash` is the SHA-256 of the canonical JSON of its `actor`, `domain`, `action`, `summary` and `detail` (keys sorted, no whitespace). Its `signature` is an Ed25519 signature over that hash. Its `hash` is the SHA-256 of `prev_hash`, `body_hash` and `signature` joined, and the first entry's `prev_hash` is 64 zeros. ## 3. Check it Download the verifier, [verify-ledger-export.mjs](https://www.vestiarion.xyz/tools/verify-ledger-export.mjs). It is one file with no dependencies beyond Node.js 20 or later. Then run: ```bash node verify-ledger-export.mjs vestiarion-your-workspace-ledger-392.json ``` It answers with one of three results: - **VALID** (exit code 0): every entry's content matches its hash, every signature verifies, and every link holds, from the first entry to the head. It prints the head and the key ids it trusted. - **BROKEN** (exit code 1): it names the first entry that fails and why: its content was changed, its signature does not verify, an entry is missing or out of order, or an entry is malformed, such as a missing field or a hash that is not hex. - **NOT CHECKED** (exit code 2): it could not check the file at all. The file is not an export, an entry names a key the file does not hold, or an entry was signed by a key you did not pass with `--public-key`. ## 4. Compare the key id and the head A file can only show that it agrees with itself. Someone who rewrote the entries could re-sign them with a key of their own and put that key in the file. So, before relying on a **VALID** result, compare the key id and head hash the verifier prints with the workspace's **Audit log** page. The key id is shown beside **Ledger signing public key**, and the head hash has a copy button. The page says: "Compare this key id and the head hash with the ones a verified export prints." If you already hold the workspace's public key, pass it in, and only the key(s) you pass are trusted: ```bash node verify-ledger-export.mjs vestiarion-your-workspace-ledger-392.json --public-key workspace-key.pem ``` If the workspace has rotated its signing key, an entry from before the rotation needs the retired key, not just the current one — the Audit log page lists every key it accepts. Pass every one of them: repeat `--public-key`, once per key, ```bash node verify-ledger-export.mjs vestiarion-your-workspace-ledger-392.json --public-key current-key.pem --public-key retired-key.pem ``` or put all the public keys in one file, one after another, and pass that file once. ## 5. After a key rotation An owner can replace a workspace's signing key: open **Settings** → **Ledger signing key** → **Rotate signing key**. They might do this because a key is suspected of leaking, or just as routine hygiene — either way, entries already written keep verifying, since each one carries the id of the key that signed it. Anyone else who opens that panel sees why they can't: "An owner of this workspace can rotate the key." The old public key does not disappear. It stays listed under **Retired keys**, both on the **Ledger signing key** panel in Settings and on the **Audit log** page, next to the current key. The rotation itself is recorded in the ledger like any other decision, as `ledger_key_rotated`, naming both the retired and the new key id and who rotated. For verifying an export from a workspace that has rotated its key, see [§4](#4-compare-the-key-id-and-the-head) above: pass every key the export's entries were signed with, the retired one and the current one, by repeating `--public-key` or listing them in one file. If you operate a deployment that still sets `LEDGER_SIGNING_KEY` for the founding workspace, that environment copy becomes a retired private key once the workspace rotates: remove it from hosting. The app reads the signing key from the workspace itself, and `npm run org:adopt-env` refuses a retired key. --- # When the model and the policy disagree > Production decisions beside the written policy's answer to the same facts: where they differed, and where code refused the model. Vestiarion's agent decides with a language model. For the same facts, the code also works out what a written policy would decide, and the signed ledger records both answers side by side. Whatever either says, guardrails in code still decide whether a payment is made. This note reads every decision the agent has made in production so far. It covers how often the model chose the policy's action, where it did not, and what followed. Since its second week, it also covers what people did with what the agent left them. The first week's figures are as of 1 October 2026, 05:48 UTC; the second week's, as of 2 October 2026, 16:43 UTC; the third week's, as of 5 October 2026, 00:40 UTC. All come from `npm run research:model-vs-policy`; `-- --from` and `-- --to` measure one window. The running totals are on [www.vestiarion.xyz/open](https://www.vestiarion.xyz/open). ## How it is measured - **The model** (DeepSeek in production) is given the facts of a decision. For a payable, those are: - the amount, its purchase-order reference, and whether goods were received; - the counterparty's risk level, its payment limit and its history; - the balances; - the duplicate check; - the payment timing figures. It answers with an action and its reasoning. For a payable or a milestone, it also gives a confidence between 0 and 1; a treasury decision gives an amount instead. - **The written policy** is the same rules, written as code. `decide()` works out the policy's answer for every decision. Vestiarion uses that answer when the model's call fails, when its reply is still unusable after one retry, or when no model is configured. - **The model is not sent the policy's action.** It does get the rules, in its instructions. In places it also gets the policy's own working: - for payment timing, the day the policy would pay on, and whether cash falls short by then; - for the treasury, the buffer, the projected yield and the round-trip cost, with the rule that a sweep must earn more than it costs. Agreement on those is close to built in. The fairer test is where the model weighs evidence: limits, the three-way match and duplicates. - **Each decision's ledger entry** records: - `decision`, the model's answer; - `referenceDecision`, the policy's answer; - `agreedWithReference`; - when code refused the model, `guardrailRule` for a payable, or `guardrailBlocked` for a milestone. Two answers agree when they choose the same action, and, for two schedules, the same day. A payable is compared after code has bounded the model's date. Since October 3, 2026, two treasury answers agree only when their amounts are also within 5% of the policy's, or 0.01 USDC. The script judges every entry this way. Every treasury decision in weeks one and two was a hold, so their figures are unchanged; week three is the first where the amount decides it. - **Guardrails** run after the model, whatever it says. For a payable, code refuses any payment, now or scheduled, that: - is over the counterparty's limit; - goes to a counterparty screened as high risk; - goes to an address that changed and is not yet confirmed; - bills the same purchase order for the same amount as an invoice already paid, being paid, or scheduled; - is in EURC with no rate or not enough EURC; - goes to a payee on another chain in EURC, or with no acceptable fee. Code leaves the invoice held or flagged for a person. For a milestone, it checks the risk level and the limit. For both, a daily spending limit set on the workspace is checked last. - **What people did** is in the ledger too: Approve and pay, Reject and Return on a payable; Pay now and Close without paying on a milestone. For each, the script finds the agent's last decision on the same invoice or milestone, and says why it waited for a person: - the agent stopped it as the written policy would, for example above a limit; - the model stopped it where the policy would have paid; - code refused the model's payment; - or the agent paid, and the payment did not go through. ## Week one From 24 September to 1 October 2026, the agent made 141 decisions in 5 workspaces. Some were made in production on Arc testnet, and some in sandboxes. DeepSeek made every one of them; the rule-based fallback never had to decide. The first 14, on 24 September, were made before the policy's answer was recorded beside the model's. That leaves 127 to compare. Two of them, treasury holds, were in a workspace deleted since, so the script now counts 139 decisions and 125 compared for this week; nothing else changes. | | Compared | Same action as the policy | |---|---|---| | Payables | 39 | 32 (82.1%) | | Contractor milestones | 4 | 4 | | Treasury | 84 | 84 | | **All** | **127** | **120 (94.5%)** | The total flatters the model. Every treasury decision was to hold: at testnet balances, the yield a sweep would earn was less than its fees, and the model is handed those figures and that rule. The decisions that test judgement are the payables, and there the model and the policy chose differently 7 times in 39. The model's stated confidence was lower where it differed. It averaged 0.75 across those seven, against 0.94 across the 36 payable and milestone decisions where it matched; treasury decisions record none. Four of the seven still carried 0.85 or more. ### The seven differences | Entry | Invoice | Model | Policy | Confidence | What followed | |---|---|---|---|---|---| | #343 | Centronex, 3 USDC | flag | hold | 0.85 | A person approved and paid it. | | #386 | Harbor Office Supply, 1,200 USDC (added by hand in the sample-data sandbox) | request information | hold | 0.95 | Removed with the sample data. | | #387 | Kestrel Print Co, 180 USDC (added by hand in the sample-data sandbox) | flag | pay | 0.6 | Removed with the sample data, unpaid. | | #426 | Centronex, 2 USDC, PO-100 | flag | pay | 0.6 | Waiting for a person. | | #429 | Centronex, 2 USDC, PO-103 | flag | pay | 0.85 | Waiting for a person. | | #462 | Zenith Trading LLC, 2.6 USDC | request information | hold | 0.88 | Waiting for the information. | | #505 | Trading Handrock, 1.9 EURC | pay | hold | 0.5 | Refused by code, and held for a person. | They fall into three kinds. #### Stricter than the policy: three possible duplicates Three times the policy would have paid, and the model flagged the invoice instead (#387, #426, #429). Each time, the duplicate check had found another invoice from the same vendor, with the same amount and a close due date, at a confidence of 0.60. No purchase order matched. In #426 and #429, one of the matching invoices was already paid. The policy stops a payment as a duplicate when it bills the same purchase order for the same amount as an invoice already paid, being paid, or scheduled. Code enforces the same stop whatever the model says. These matches were weaker, so the policy paid. The model was shown the same matches, with a note that a repeat of an invoice already paid is duplicate billing, and it stopped. Both Centronex invoices were tests one of us ran against the same small vendor, each under its own purchase order. These were false alarms: they cost a person's attention, not money. A treasury can afford to err this way more than the other way. It still matters, because a person who clears flags all day stops reading them. #### A different stop: three invoices over their limit Three invoices were over the counterparty's limit, so neither the model nor the policy would pay them. The policy holds such an invoice for a person to approve and pay, reject or return. The model chose a different stop: - It asked for information where the three-way match was incomplete as well: in #386 the goods were not received, and in #462 there was no purchase order and no receipt. The policy checks the limit before the three-way match, so within the limit it would have asked for information too. - It flagged #343, where the same purchase order had been billed before at a different amount. A person later paid #343: the re-billed order was one of our tests, not fraud. #### Looser than the policy: one payment over a limit, refused by code Entry #505 is the only one of the 127 compared where the model would have paid and the policy would not. The invoice was 1.9 EURC, worth 2.310316 USDC at Circle's quote, against a counterparty limit of 2 USDC. The model's own reasoning says the invoice is "ABOVE that limit — a hard guardrail breach". It answered `pay` all the same, with a confidence of 0.5. The payment-limit guardrail refused the payment, and the invoice was held for a person. The reasoning was right and the action was wrong. This is why code checks the action, not the prose. ### What we took from week one - **It stopped payments the policy would have made more often than the reverse: three times to one**, in a small sample. In the other three differences, the policy stopped the payment too. - **It was wrong in both directions.** - A person paid one of its flags (#343). - Two more flags were our own tests, each under its own purchase order, and they still wait for a person. - Its one looser answer contradicted its own reasoning. None moved money wrongly: flags wait for a person, and code refused the payment. - **Its confidence was lower where it differed**, but seven differences cannot show that confidence would separate the two. - **Code refused one payment in the week:** #505, the only decision where the model would have paid and the policy would not. ## What we changed, and a replay The three duplicate flags came from the instructions. The written policy stops a payment as a duplicate only when it bills the same purchase order for the same amount. The model, though, was told that any repeat of an invoice already paid is duplicate billing. On 1 October we changed what the model is told: - A match that bills the same purchase order for the same amount as an invoice already paid, being paid, scheduled, or being decided by a person, is duplicate billing. Code stops it either way. - A match on amount and due dates alone, under a different purchase order or none, is a signal to weigh, not proof. The model is to flag it only when other facts point the same way. To see the effect without waiting for the same cases to happen again, `npm run research:replay` asks the model again about decisions it already made, three times each: - **The question** is today's, built by the same code the agent uses. - **The facts** are those recorded in the ledger entry of each decision. Timing figures were first recorded on 1 October, from entry #472 on, so four of the five entries have none. For those, the replay rebuilds the timing those invoices had: due that day, with nothing else falling due. - **Two controls** are true duplicates from the sample data, #385 and #545. Each bills the same purchase order for the same amount as an invoice already paid. | Entry | Recorded | Old instructions | New instructions | |---|---|---|---| | #426 | flag (policy: pay) | flag, flag, flag | pay, pay, pay | | #429 | flag (policy: pay) | flag, flag, flag | pay, flag, pay | | #387 | flag (policy: pay) | flag, pay, pay | pay, pay, pay | | #385 (control) | flag (policy: flag) | flag, flag, flag | flag, flag, flag | | #545 (control) | flag (policy: flag) | flag, flag, flag | flag, flag, flag | With the old instructions, the three false alarms were flagged 7 times in 9. With the new ones, they were flagged once in 9. The true duplicates were flagged every time, before and after. A replay of three runs per case is a check, not proof. The agreement rate on payables in production would show whether the change holds. Week two is the first look. ## Week two From 1 October, 05:48 UTC, to 2 October, 16:43 UTC, the agent made 72 decisions in 5 workspaces, all by DeepSeek, and every one recorded the policy's answer beside the model's. Eight were in one live workspace that /open counts as a customer's, because its creator is not on the platform team. | | Compared | Same action as the policy | |---|---|---| | Payables | 13 | 10 (76.9%) | | Contractor milestones | 12 | 11 | | Treasury | 46 | 46 | | Limit proposals | 1 | 1 | | **All** | **72** | **68 (94.4%)** | Across both weeks, the model chose the policy's action 186 times in 197: 94.4%, and 42 times in 52 on payables (80.8%). Treasury decisions were all holds again, for the same reason as in week one. The model's confidence was again lower where it differed: 0.86 on average across the four differences, against 0.93 where it matched. ### The four differences | Entry | Decision | Model | Policy | Confidence | What followed | |---|---|---|---|---|---| | #570 | Gozo, 1 USDC, PO-108 | flag | pay | 0.7 | A person approved and paid it (#588). | | #622 | an invoice in the workspace /open counts as a customer's | hold | request information | 0.9 | Waiting for a person. | | #641 | Quoc Duong, milestone, 1 USDC | hold | release | 0.9 | Paid by the agent a day later (#908), once a person had reviewed its screening. | | #686 | CME, 0.6 USDC, to a payee on Arbitrum Sepolia | pay | hold | 0.93 | Refused by code; a person approved and paid it (#717). | #### The duplicate change mostly held #570 is the one possible duplicate the model flagged where the policy would have paid. It was decided at 08:22 UTC on 1 October, an hour and a half after the instructions changed. The match was the same kind as before: an earlier Gozo invoice, already paid, with the same amount and a close due date, at a confidence of 0.60. The model's reasoning quotes the new instruction: a match on amount and dates alone "is only a signal, not proof". It then counts the same amount and the close date as the other facts that point the same way, and flags. A person paid it. That is one flag in the 13 payables this week, against three in 39 the week before, and as in the replay, not none. #### Right reasoning, wrong action, again #686 is the week's only decision where the model would have paid and the policy would not. The payee is on Arbitrum Sepolia, and Circle's CCTP fee was 0.112521 USDC: 18.75% of a 0.6 USDC invoice, above the 10% a payout may cost. The model's reasoning says the fee is "above the 10% threshold, so the payout is refused". It then decides to pay "on Arc in USDC", which this payee cannot receive. Code refused it, and a person paid it with the fee. This is #505 again: the reasoning names the rule, and the action breaks it. Code checks the action. #### A milestone held for two reasons, one of them wrong In #641, the model held a 1 USDC milestone for two reasons. The first was right: a screening match had cut the contractor's limit to 0.25 USDC, so code would have refused the release anyway. The second was wrong: it read a milestone verified by hand as not verified, because the facts it was shown did not say how a milestone was verified. That was fixed 17 minutes later, and the model is now told whether a person, a merged pull request or a timesheet verified the work. The screening match was a namesake. The name matched 19 politically exposed people, and a person dismissed them all, the last 15 in a single review. Screening then cleared the contractor, the agent reopened the milestone, and released it within a minute (#908). #### Code refused three payments Besides #686, code refused two payments the model and the policy both chose. Each would have taken the agent past the workspace's 5 USDC daily spending limit: - #691, for 0.6 USDC. When the limit had room again, the agent reopened it and paid it itself (#697). - #817, for 3 USDC. It is waiting for a person. That makes four payments refused by code in two weeks, out of 211 decisions: #505, #686, #691 and #817. ## Week three From 2 October, 16:43 UTC, to 5 October, 00:40 UTC, the agent made 139 decisions in 5 workspaces, all by DeepSeek, and every one recorded the policy's answer beside the model's. Eleven were in the live workspace /open counts as a customer's. | | Compared | Same action as the policy | |---|---|---| | Payables | 36 | 31 (86.1%) | | Contractor milestones | 5 | 5 | | Treasury | 96 | 81 (84.4%) | | Reminders to clients | 2 | 2 | | **All** | **139** | **119 (85.6%)** | Across the three weeks, the model chose the policy's action 305 times in 336: 90.8%, and 73 times in 88 on payables (83.0%). Its confidence was again lower where it differed: 0.89 on average, against 0.94 where it matched. This was the first week the treasury moved money. The reserve became real USYC on Arc testnet on 3 October, and 15 of the week's 20 differences were treasury decisions. ### The treasury, with real USYC #### Too much, then bounded by code In #1063, at 07:43 UTC on 3 October, the operating wallet was empty, 58.21 USDC sat in the reserve, and 0.10 USDC fell due in two days. The policy redeemed 0.115 USDC: the bill and its 15% cushion. The model redeemed 58.1 USDC, reasoning that a redemption "costs nothing since redemptions are always possible". Measured by action alone, as this note did until then, the two agreed. That is why agreement now weighs the amount. It is also why code now bounds every move: a redemption brings back at most what falls due within 14 days needs, with its cushion, and a sweep never takes the operating wallet below its 7-day buffer. #### A wrong premise, then a right one In eight decisions the model held where the policy would have redeemed a buffer. Until 4 October its reason rested on a mistake. In #1267, with nothing in the operating wallet and 0.20 USDC due that day, it held because "the reserve already covers it without moving cash". Only the operating wallet pays anyone. In the same cycle, a milestone release had just been refused by the spending limit contract on Arc, because the wallet it would pay from was empty (#1266). Two changes followed: - each cycle now brings back from USYC what the day's payables and verified milestones need, before it decides them; - the model is told that payments leave only from the operating wallet. Its next decision (#1336) saw that the wallet was short, and redeemed 0.115 USDC as the policy would. When it held again (#1354), it cited the right facts: "the reserve's 154.27 USDC is redeemed automatically on the day a payment needs it". That hold still counts as a difference, and it is one the design allows: code bounds what a move may be, and a hold is the model's to make. #### A thinner margin than the policy's In six decisions the model declined sweeps of 97 to 119 USDC that the policy would have made (#1070 to #1103). The projected yield was 0.02 USDC over the 1.7 days before the next bill, against a round trip of 0.01 USDC. The policy sweeps whenever the yield beats the cost. The model wanted a wider margin, and said so: "the gain is too thin to justify locking the cash up". ### Payables: looser than the policy on purchase orders Four of the five payable differences were one rule, the three-way match. Each invoice had its goods received and no purchase order, and the policy asked for information. The model paid two of them, 0.5 USDC each to Centronex (#1172, #1182). It scheduled the other two: 3.5 USDC to STM for 15 October (#1297), and 0.8 USDC to Puka Hotel for 3 November (#1302). One of its reasons says that "no three-way match is required for this service invoice". Code does not check the match, so nothing stopped these. Before this week, every time the model was looser than the policy, a check in code refused it: a limit (#505) or a fee cap (#686). This is the first looser call that moved money with no check in its way. What we take from it: whether an invoice needs a purchase order is the business's rule to set, not the model's to waive. Since 5 October, code checks the match. A payment or a schedule on an incomplete match is refused, and the invoice waits for its details. A business marks on **Counterparties** the payees it pays without purchase orders. The fifth difference, #977, is the third time the model named a rule and then broke it. It called the payout's fee "37.3% of the invoice and too high", and chose to pay anyway. Code refused it as above the fee cap, and a person paid it. ### Code refused seven payments - Four would have taken the agent past the daily spending limit of testnet-2: #941, #990, #995 and #1079. The last was a payable to a client, entered by mistake as one to pay. Code now holds any payable to a client. - #977, above the payout fee cap. - #1118, to an address that arrived through the API and that no one had confirmed yet. - #1266, a milestone release that the spending limit contract on Arc refused, because the wallet was empty. That makes 11 payments refused by code in three weeks, out of 336 decisions. ## People and the agent In weeks one and two together, people decided 15 payables and milestones the agent had left them. | Why it waited for a person | Paid | Rejected or closed | Returned to the agent | |---|---|---|---| | The agent stopped it, as the written policy would | 5 | 1 | 1 | | The model stopped it; the policy would have paid | 3 | 0 | 0 | | Code refused the model's payment | 2 | 0 | 0 | | The agent paid, and the payment did not go through | 2 | 1 | 0 | - **Every stop only the model made was overruled.** All three were possible duplicates (#426, #429, #570), and a person paid each one. On this evidence, the model's extra caution on duplicates costs attention and has caught nothing yet. - **Stops the policy makes too mostly ended in a payment.** A payable above its limit waits for a person to approve it: that is the rule, not a disagreement. People paid five of these, rejected one and returned one to the agent. One of the five was a true duplicate in the sample-data sandbox (#391), paid by one of us testing the approval path. - **Both refusals by code that reached a person were paid.** One was above the limit (#505), one cost more than the fee cap (#686). Code moved each decision to a person, who accepted the amount or the fee. - **Three milestone payments did not go through.** Circle refused the first batch of three at estimation, and nothing moved. A person sent two again with Pay now, and closed the third without paying. - **Screening was overruled by name.** Five reviews dismissed 19 screening matches, all for one contractor, as other people with a similar name. - **The one limit the agent proposed raising was accepted.** People agreed with the model's own extra stops none of the three times they decided one. That is the clearest signal in two weeks, and it points the same way as week one. In week three, people decided seven more: - of five the agent stopped as the policy would, they paid two, rejected or closed two, and returned one to the agent; - of two that code refused, they paid one and rejected one. None was a stop the model made alone. This week the model's departures went the other way, toward paying (see purchase orders, above). ## The limits of this note - **The sample is small.** 336 decisions over three weeks, from one provider, cannot show a trend. The people figures rest on 22 decisions. - **Nearly every decision was made in a workspace we run or test with:** the founding workspace, testnet-2, sandboxes, a fresh account one of us opened to test [Try it in 5 minutes](https://www.vestiarion.xyz/docs/guides/try-it), and two workspaces made to test the freelancer and milestone paths. The exception is the live workspace that /open counts as a customer's. - /open counts that last account as a customer's, because its creator is not on the platform team, so its 25 decisions (14 in week two, 11 in week three) appear on the customers' side there. - Several of the invoices were built to exercise a rule, so the rate says little about everyday invoices. - No outside business's invoice has been decided yet. - **Agreement compares actions, and for the treasury since 3 October amounts, but never reasons.** Two holds for different reasons count the same. #1267 and #1354 are both differences, though only the second rests on the facts. ## Check it yourself - Every entry named here is signed with Ed25519 and linked to the entry before it, in its workspace's ledger. A member of the workspace can export that ledger and check it without Vestiarion; see [Verify an audit export](https://www.vestiarion.xyz/docs/guides/audit-export). - With access to the production database, `npm run research:model-vs-policy` recomputes the counts, the rates, the table of differences and what people did, read-only; `-- --from 2026-10-01T05:48:00Z --to 2026-10-02T16:43:00Z` measures week two alone, and `-- --from 2026-10-02T16:43:00Z --to 2026-10-05T00:40:00Z` week three. It counts a customer's workspace but never names it. The rest is read from the entries named here. - `npm run research:replay -- 426 429 387 385 545 --runs 3` repeats the replay. It reads the database read-only and records nothing; a model answers differently from run to run, so expect the same pattern, not the same answers. --- # Quickstart > Create a workspace API key and make your first requests. This page takes you from no key to reading a workspace's ledger. You need a Vestiarion workspace in which you are an owner or an admin, and `curl`. Using the app rather than the API? Start with [Go live on Arc testnet](https://www.vestiarion.xyz/docs/guides/go-live). Writing TypeScript? The [TypeScript SDK](https://www.vestiarion.xyz/docs/get-started/sdk) wraps these requests, with types, every page and safe retries. ## 1. Create an API key 1. Open your workspace and go to **Settings**, at `/o//settings`. 2. Under **API keys**, choose **Create key**, and give the key a name that says what will use it, such as "Reporting integration". 3. Copy the key. It is shown once, in full, right after you create it, and never again. Only an owner or an admin can create a key; every other member sees the list of keys without the controls. A workspace holds at most 20 active keys. A key works for as long as you stay a member of the workspace. Keep the key in an environment variable rather than in your code: ```bash export VESTIARION_API_KEY="vxk_..." ``` > **The key reads the whole workspace** Treat it as a password. Never put it in a URL or in code that runs in a browser. [Authentication](https://www.vestiarion.xyz/docs/get-started/authentication#keeping-a-key-safe) says why. ## 2. Check the workspace Your first request asks for the workspace's status: ```bash curl https://www.vestiarion.xyz/api/v1/status \ -H "Authorization: Bearer $VESTIARION_API_KEY" ``` A real response looks like this: ```json { "data": { "businessName": "Vestiarion workspace", "provenance": { "payments": "live", "yield": "simulate", "screening": "simulate" }, "clock": { "mode": "simulate", "day": 25, "lastCycleAt": "2026-09-24T18:33:04.546517+00:00" }, "totals": { "decisionsLogged": 77, "totalPaidOut": 4.815, "flagged": 1 }, "configuration": { "businessName": "Vestiarion workspace", "chain": { "circleConfigured": true, "arcRpcConfigured": false }, "llm": { "pinned": null, "available": [ "deepseek" ] }, "compliance": { "mode": "bundled", "rescreenIntervalHours": 0 }, "followUp": { "staleAfterDays": 3, "reEscalateAfterDays": 7 }, "ledgerSigningKeyProvided": false, "githubTokenProvided": false, "clockMode": "simulate" }, "apiVersion": "v1" } } ``` Every success carries its result in `data`. `provenance` tells you whether this workspace's payments and yield are `live`, `simulate` or `unavailable`, so check it before you treat a payment as real. The [status reference](https://www.vestiarion.xyz/docs/api/get-status) describes every field. If you get `401` instead, the key is missing, mistyped or revoked: see [Errors](https://www.vestiarion.xyz/docs/get-started/errors). ## 3. Read the ledger The ledger is the workspace's signed, hash-chained record: each decision the agent made, and each action a person took, is an entry. Ask for the five oldest entries: ```bash curl "https://www.vestiarion.xyz/api/v1/ledger?limit=5" \ -H "Authorization: Bearer $VESTIARION_API_KEY" ``` The response below is real, captured with `limit=1`, so it holds one entry. With `limit=5`, `data` holds up to five, and `page.count` says how many: ```json { "data": [ { "seq": 82, "id": "4566714f-a9f0-42c8-bcd4-d89adf830806", "ts": "2026-09-24T11:55:00.370366+00:00", "actor": "system", "domain": "system", "action": "seed", "summary": "Seeded demo business: Northstar Studio", "detail": { "accounts": 3, "invoices": 6, "milestones": 3, "amountScale": 0.001, "counterparties": 7 }, "bodyHash": "8780d07cb3d083d359119e58fd4783f5e4b32aac14e0a8d9f7a5bb13365a4537", "signature": "c14f06b751d31be6566ed11676d60d2db731ab8bbc5972f79d0a0c131e9e37200303d5b3ad04993b7973b1ce7200e7214e5a9b511306fa30538f072eb0d34705", "prevHash": "0000000000000000000000000000000000000000000000000000000000000000", "hash": "b0ac72908868ddd5ed4722fd8a14ff4a844eee4dad382c5c85288bca73737669", "signingKeyId": null } ], "page": { "nextCursor": "eyJrIjo4Mn0", "hasMore": true, "count": 1 } } ``` `hasMore` is `true`, so there are more entries. To get them, send `page.nextCursor` back unchanged as `cursor`: ```bash curl "https://www.vestiarion.xyz/api/v1/ledger?limit=5&cursor=eyJrIjo4Mn0" \ -H "Authorization: Bearer $VESTIARION_API_KEY" ``` ## Next steps - [Pagination](https://www.vestiarion.xyz/docs/get-started/pagination): page through a collection, and follow the ledger with a stored cursor. - [The endpoint overview](https://www.vestiarion.xyz/docs/api): every endpoint. Each reference page has a [Try it](https://www.vestiarion.xyz/docs/api/list-invoices#try-it) panel that sends a real request with your key. - [Webhooks](https://www.vestiarion.xyz/docs/webhooks): receive each new ledger entry without polling. - [Errors](https://www.vestiarion.xyz/docs/get-started/errors) and [Limits](https://www.vestiarion.xyz/docs/get-started/limits): what to retry, and when to back off. - [AI integration](https://www.vestiarion.xyz/docs/ai-integration): point a coding agent at these docs. --- # TypeScript SDK > A typed client for the API and its webhooks: every page, safe retries and signature checks. The TypeScript SDK is a typed client for this API and its webhooks. It gives you: - every endpoint, with its types; - pagination that reads every page; - retries that never add a record twice; - checks for webhook signatures and ledger entries. It has no dependencies. It runs on Node 20 or later, Deno, Bun and edge runtimes such as Cloudflare Workers. ## Install ```bash npm install @vestiarion/sdk ``` The package is [@vestiarion/sdk on npm](https://www.npmjs.com/package/@vestiarion/sdk). pnpm, Yarn and Bun install it by the same name, with `pnpm add`, `yarn add` or `bun add`. It is ESM only. This site serves the same package, byte for byte, for a lockfile that should not depend on the npm registry: ```bash npm install https://www.vestiarion.xyz/sdk/vestiarion-sdk-0.2.0.tgz ``` Each version has its own URL there, and the file behind it never changes, so the integrity hash in your lockfile keeps matching. Version 0.2.0 adds milestones and payee links; 0.1.0 is still served at its own URL. ## Create a client ```ts import { Vestiarion } from "@vestiarion/sdk"; const vestiarion = new Vestiarion({ apiKey: process.env.VESTIARION_API_KEY! }); ``` The key is a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication). Keep it on a server: it reads the whole workspace. The client refuses a value that is not shaped like a key, without repeating the value in the error. | Option | Default | | --- | --- | | `baseUrl` | `https://www.vestiarion.xyz`. Plain `http` is refused, except to localhost, so the key never travels in the clear. | | `fetch` | The runtime's own `fetch`. Pass another to route, record or proxy requests. | | `maxRetries` | `2` | | `timeoutMs` | `30000`, per attempt. | ## Read Each [API operation](https://www.vestiarion.xyz/docs/api) is one method. A resource returns the API's `data`. A list returns `{ data, page }`, exactly as the API answers it. ```ts const status = await vestiarion.status.get(); const { data: held, page } = await vestiarion.invoices.list({ status: "held", limit: 20 }); const counterparty = await vestiarion.counterparties.get("dc5e5751-3287-46c9-8bd1-83a42ab02699"); const verification = await vestiarion.ledger.verify(); ``` Each result is typed from the API's [OpenAPI document](https://www.vestiarion.xyz/api/v1/openapi.json), as `Invoice`, `Counterparty`, `LedgerEntry` and so on. Your editor shows each field's description. | Method | Operation | | --- | --- | | `vestiarion.status.get()` | [Get workspace status](https://www.vestiarion.xyz/docs/api/get-status) | | `vestiarion.ledger.list(params)`, `vestiarion.ledger.listAll(params)`, `vestiarion.ledger.pages(params)` | [List ledger entries](https://www.vestiarion.xyz/docs/api/list-ledger-entries) | | `vestiarion.ledger.verify()` | [Verify the ledger](https://www.vestiarion.xyz/docs/api/verify-ledger) | | `vestiarion.invoices.list(params)`, `vestiarion.invoices.listAll(params)`, `vestiarion.invoices.pages(params)` | [List invoices](https://www.vestiarion.xyz/docs/api/list-invoices) | | `vestiarion.invoices.create(input, options)` | [Add an invoice](https://www.vestiarion.xyz/docs/api/create-invoice) | | `vestiarion.counterparties.list(params)`, `vestiarion.counterparties.listAll(params)`, `vestiarion.counterparties.pages(params)` | [List counterparties](https://www.vestiarion.xyz/docs/api/list-counterparties) | | `vestiarion.counterparties.get(id)` | [Get a counterparty](https://www.vestiarion.xyz/docs/api/get-counterparty) | | `vestiarion.counterparties.create(input, options)` | [Add a counterparty](https://www.vestiarion.xyz/docs/api/create-counterparty) | | `vestiarion.payeeLinks.create(input)` | [Create a payee link](https://www.vestiarion.xyz/docs/api/create-payee-link) | | `vestiarion.milestones.list(params)`, `vestiarion.milestones.listAll(params)`, `vestiarion.milestones.pages(params)` | [List milestones](https://www.vestiarion.xyz/docs/api/list-milestones) | | `vestiarion.milestones.create(input, options)` | [Add a milestone](https://www.vestiarion.xyz/docs/api/create-milestone) | | `vestiarion.treasury.get()` | [Get the treasury](https://www.vestiarion.xyz/docs/api/get-treasury) | | `vestiarion.insights.get()` | [Get insights](https://www.vestiarion.xyz/docs/api/get-insights) | ## Every page ```ts for await (const invoice of vestiarion.invoices.listAll({ direction: "payable" })) { console.log(invoice.id, invoice.status, invoice.agentReasoning); } ``` `listAll` follows each `page.nextCursor` until the collection ends, keeping the same filters. To keep a copy of the ledger current, page through it with `pages`. Store each page's `nextCursor` once you have processed that page, as [Pagination](https://www.vestiarion.xyz/docs/get-started/pagination) advises: ```ts const cursor = await loadStoredCursor(); // the last nextCursor you stored, or undefined for await (const { data, page } of vestiarion.ledger.pages({ cursor })) { await saveEntries(data); // de-duplicate on seq: the last page comes back again on resume if (page.nextCursor) await storeCursor(page.nextCursor); } ``` ## Add records A key with [write access](https://www.vestiarion.xyz/docs/get-started/authentication#scopes) adds counterparties, invoices, milestones and payee links: - The agent decides each invoice as it decides one typed into the console, with every guardrail. - A milestone starts pending. GitHub verifies it once its pull request is merged, or a person does; then the agent decides. - An address added this way, or entered by a payee through a link, waits for a person in the workspace to confirm it. [Add invoices from your own system](https://www.vestiarion.xyz/docs/guides/api-invoices) and [Pay for merged pull requests](https://www.vestiarion.xyz/docs/guides/api-milestones) walk through each flow. ```ts const counterparty = await vestiarion.counterparties.create( { name: "Quill Studio", role: "vendor", address: "0x5b2d8c1f0e7a4936b8d1c0e2f3a4b5c6d7e8f901", paymentLimit: "500" }, { idempotencyKey: "crm-vendor-1042" } ); const invoice = await vestiarion.invoices.create( { counterpartyId: counterparty.id, amount: "420.00", dueDate: "2026-10-31", poReference: "PO-4012", goodsReceived: true }, { idempotencyKey: "billing-inv-2026-0042" } ); ``` To pay a contributor once their pull request merges, add a milestone with the pull request as its evidence, and ask a new payee for their address with a link: ```ts const milestone = await vestiarion.milestones.create( { contractorId: contractor.id, title: "CSV export for EURC", amount: "150.00", verificationSource: "https://github.com/acme/app/pull/42" }, { idempotencyKey: "pr-acme-app-42" } ); const link = await vestiarion.payeeLinks.create({ counterpartyId: contractor.id }); // Send link.url to the payee: it is shown only in this answer. ``` Every write except a payee link carries an `Idempotency-Key`: - **Pass your own record's id.** Then a retry of your own job cannot add the record twice either. - **Pass nothing.** Then the SDK makes a fresh key for that call, so its own retries stay safe. A payee link takes no key. The API keeps only the link's hash, so it cannot give the same link back: a repeat makes a new link, and the unused one stops working. ## Errors and retries Every failed request throws a `VestiarionError`: ```ts import { VestiarionError } from "@vestiarion/sdk"; try { await vestiarion.invoices.create(input, { idempotencyKey: input.reference }); } catch (error) { if (error instanceof VestiarionError && error.code === "invalid_request") console.error(error.message); // names the field else throw error; } ``` | Field | Meaning | | --- | --- | | `status` | The HTTP status, or `0` when no answer arrived. | | `code` | One of the API's [error codes](https://www.vestiarion.xyz/docs/get-started/errors), or `network_error`, `timeout` or `invalid_response`. | | `message` | The API's own message. | | `retryAfter` | The seconds `Retry-After` asked for, or `null`. | By default a request is retried twice (`maxRetries`) after: - a `429`, waiting as long as `Retry-After` says. A wait longer than 60 seconds is not made: the `429` is thrown, so a call never blocks for minutes; - a `500`, `502`, `503` or `504`, backing off from half a second up to 8 seconds; - a timeout, or a network failure. A `400`, `401`, `403` or `404` is thrown at once. A write's retry repeats its key and its body, so it can never add the record twice. If the API is still handling the first attempt, it answers the retry with `409`, and the SDK waits that out too. A payee link's retry makes a new link, which replaces the first. ## Webhooks ```ts import { verifyLedgerEntry, verifyWebhook, WebhookVerificationError } from "@vestiarion/sdk/webhooks"; // app/api/vestiarion/route.ts, a Next.js route handler export async function POST(request: Request) { try { const event = await verifyWebhook({ secret: process.env.VESTIARION_WEBHOOK_SECRET!, payload: await request.text(), signature: request.headers.get("vestiarion-signature"), }); if (event.type === "ledger.appended" && event.entry) { const check = await verifyLedgerEntry(event.entry, process.env.VESTIARION_LEDGER_PUBLIC_KEY!); if (check.ok === false) return new Response(check.reason, { status: 400 }); // De-duplicate on event.id, then handle event.entry. } return new Response(null, { status: 204 }); } catch (error) { if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 401 }); throw error; } } ``` `verifyWebhook` checks the `Vestiarion-Signature` header against the raw body, as [Verifying signatures](https://www.vestiarion.xyz/docs/webhooks/verify) describes, and refuses a signature more than 5 minutes from now. - If the check fails, it throws a `WebhookVerificationError` whose `reason` is `missing`, `malformed`, `expired` or `mismatch`. - Nothing is parsed before the signature checks out. - Pass the body as the text it arrived as: a copy that was parsed and serialized again differs byte for byte. `verifyLedgerEntry` checks the entry itself: - its content against `bodyHash`; - the workspace key's Ed25519 signature; - the chain hash. Pass it the public key from the workspace's Audit page, or, after a key rotation, a map of key id to public key. | Answer | Meaning | | --- | --- | | `{ ok: true }` | The entry is authentic. | | `{ ok: false, reason }` | A check failed; `reason` says which. | | `{ ok: null, reason }` | None of the keys you passed signed this entry, so there is no verdict. | ## What it sends - **Every request** carries `Authorization: Bearer` with the key, and `User-Agent: vestiarion-sdk-js/` followed by the SDK's version. - **The key** never goes in a URL, and never appears in an error message. - **A write** also sends `Content-Type: application/json` and its `Idempotency-Key`, except a payee link, which sends no key. --- # Authentication > Workspace API keys: their format, scope, revocation and use. Every request to `/api/v1` authenticates with a workspace API key. There is no other credential for this API: no shared or platform-wide token, and no sign-in session. ## Sending the key Send the key in the `Authorization` header, as a bearer token: ```http GET /api/v1/status HTTP/1.1 Host: www.vestiarion.xyz Authorization: Bearer vxk_example2_NotARealKey_ExampleOnly_NotARealKey_Example ``` The API reads the key from this header only. ## The key format A key looks like `vxk__`: - `vxk_` marks it as a Vestiarion key. - `` is eight characters, `a` to `z` and `2` to `7`. It identifies the key, and Settings shows it in the list of keys, as `vxk__…`, so you can tell keys apart. - `` is 32 random bytes, base64url-encoded: 43 characters. Vestiarion stores the prefix and a SHA-256 hash of the secret, never the key itself. That is why a key is shown once, right after it is created, and cannot be shown again. If you lose one, create another and revoke the lost one. ## One key, one workspace A key belongs to the workspace it was created in, and reaches that workspace's data and no other: it reads it and, with write access, adds to it. To read two workspaces, use two keys. Asking for another workspace's counterparty by its id answers `404`, as if it did not exist. ## Creating a key An owner or an admin creates keys on the workspace's Settings page, `/o//settings`, under **API keys**. A key's name is 1 to 60 characters. A workspace holds at most 20 active keys; revoke one to make room for another. A key works for as long as the person who created it is a member of the workspace (see [Revoking a key](#revoking-a-key)). Every other member of the workspace sees the list of keys, without the controls to create or revoke one. The [Quickstart](https://www.vestiarion.xyz/docs/get-started/quickstart#1-create-an-api-key) walks through it. Tick **Can also add records** to give the key write access, and leave it clear for a key that only reads. A key's access is set when it is created: to change it, create a new key and revoke the old one. ## Scopes A key is read-only, or it reads and writes: - **Read only**, the scope `read`: every `GET` endpoint. Every key has it. - **Read and write**, the scopes `read` and `write`: also [Add a counterparty](https://www.vestiarion.xyz/docs/api/create-counterparty), [Add an invoice](https://www.vestiarion.xyz/docs/api/create-invoice), [Add a milestone](https://www.vestiarion.xyz/docs/api/create-milestone) and [Create a payee link](https://www.vestiarion.xyz/docs/api/create-payee-link). The list of keys shows **Read and write** beside such a key. A key whose scopes do not cover a route answers `403 forbidden`, as a read-only key does on a write. A write also needs the person who created the key to be able to add records now, which owners and admins can. If an owner moves them to approver or viewer, the key keeps reading, and each write answers `403 forbidden`: "This key's issuer can no longer add records in this workspace." Write access adds records, and nothing more. A key never verifies work, approves, rejects or pays: - The agent decides an invoice added with one as it decides one typed into the console, with every guardrail. - A milestone added with one waits for GitHub, or a person, to verify it. - An address added with one, or entered by a payee through a link made with one, waits for a person in the workspace to confirm it, and the agent pays nothing to it until then. [Add invoices from your own system](https://www.vestiarion.xyz/docs/guides/api-invoices) and [Pay for merged pull requests](https://www.vestiarion.xyz/docs/guides/api-milestones) walk through it. ## Last used Settings shows when each key was last used. The time is recorded after the response is sent, at most once a minute per key, so it can trail your latest request by up to a minute. A key that shows "never", or a date long past, is a candidate to revoke. ## Revoking a key An owner or an admin revokes a key from the same list. Revoking takes effect at once and cannot be undone: from then on the key answers exactly like a key that never existed. It leaves the list for **Revoked keys**, folded below it, which shows the day each key was revoked. A key is also revoked when the person who created it stops being a member of the workspace: when they leave it, when an owner or an admin removes them, or when they delete their account. Every active key they created in that workspace, read-only or read-and-write, is revoked in the same step that ends their membership, so no key reads or adds records for someone who has left. Keys that other members created keep working, and so do the person's keys in workspaces they still belong to. Before anyone is removed, or leaves, the confirmation names the keys that will stop working. A workspace's last owner cannot leave it, so their keys stay. Creating and revoking a key each add an entry to the workspace's ledger, `api_key_created` or `api_key_revoked`. The entry names the key by its id, and `api_key_created` lists its `scopes`; the key itself is never written to the ledger. `api_key_revoked` also says why, in `reason`: - `person`: an owner or an admin revoked it in Settings. `by` is who revoked it. - `member_left`: the person who created it left the workspace. `by` is that person. - `member_removed`: an owner or an admin removed the person who created it. `by` is who removed them, and `member` is the person removed. - `account_deleted`: the person who created it deleted their account. `by` is that person. A key revoked because its creator left, or was removed, gets its own entry right after the member's `member_left` or `member_removed`. To rotate a key without downtime, create the new key, deploy it, check that its **Last used** time moves, and then revoke the old one. ## When authentication fails A missing key, a malformed one, an unknown one and a revoked one all get the same answer: ```http HTTP/1.1 401 Unauthorized Content-Type: application/json {"error":{"code":"unauthorized","message":"A valid API key is required."}} ``` The answer does not say which of the four it was, so it cannot be used to find out whether a key exists. If Vestiarion cannot look the key up at all, because of a database error, it answers `500 internal` rather than `401`. A `500` says nothing about your key, so do not discard the key on one: retry later. [Errors](https://www.vestiarion.xyz/docs/get-started/errors) lists every code. ## Keeping a key safe A key reads the whole workspace: its invoices, counterparties, treasury and ledger. A read-and-write key can also add counterparties, invoices, milestones and payee links, so give write access only to a system that adds records. - **Never put a key in a URL.** URLs end up in server logs, browser history and proxies. The API does not read a key from a query string anyway. - **Never put a key in client-side code**, such as a web page, a browser extension or a mobile app. Anyone who has the code can read the key. Call the API from your server, and pass on only what the client needs. - **Keep it in a secret store** or an environment variable, such as `VESTIARION_API_KEY`, not in source control. - **Use one key per integration**, named after it, so you can revoke one without stopping the others. - **Create a long-lived integration's key as someone who will stay.** A key stops working when the person who created it leaves the workspace. Before someone who created keys leaves, have a member who stays create replacements, and switch your integrations to them. The **Try it** panel on each read operation's [reference page](https://www.vestiarion.xyz/docs/api) keeps the key you paste in the page's memory only. An operation that adds records has no panel. It is not stored in the browser, and it is sent only in the `Authorization` header of the request you make, to this site's `/api/v1`. --- # Errors > The error codes, their HTTP statuses and the error body. A request that fails answers with an HTTP status and a JSON body that names the error with a code. Branch on the code, not on the message: the codes are a closed set, and the message is written for a person. ## The error body ```json {"error":{"code":"invalid_request","message":"riskLevel must be one of unscreened, clear, medium, high."}} ``` - `error.code` is one of the codes below. - `error.message` says what went wrong. For `invalid_request` it names the parameter and, for a filter, the values it accepts. It never carries implementation details such as a database error. ## Codes | HTTP | Code | Meaning | Retry? | | --- | --- | --- | --- | | 400 | `invalid_request` | An invalid `limit` or `cursor`, a filter value outside its allowed values, or a request body that does not validate. The message names the parameter or the field, and lists the accepted values. | No. Fix the request. | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | No. Check the key. | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | No. | | 404 | `not_found` | The requested resource does not exist in the key's workspace. | No. | | 409 | `conflict` | The `Idempotency-Key` was already used for a different request, or the first request with it is still being handled. | Not with that key. Send a new request with a new key, or wait and repeat the first one unchanged. | | 429 | `rate_limited` | Too many requests; wait as long as `Retry-After` says before trying again. | Yes, after `Retry-After` seconds. | | 503 | `unavailable` | A service the request depends on is unavailable. | Yes, with backoff. | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | Yes, with backoff, a few times. | Each [reference page](https://www.vestiarion.xyz/docs/api) lists, [under Errors](https://www.vestiarion.xyz/docs/api/list-invoices#errors), the codes that endpoint can return. ## Which errors to retry - **Do not retry** a `400`, `401`, `403` or `404` unchanged: the same request gets the same answer. - **Retry** a `429`, `503` or `500` with exponential backoff: wait, then double the wait each time, with some random jitter, and give up after a few attempts. On a `429`, wait at least as long as the `Retry-After` header says. [Limits](https://www.vestiarion.xyz/docs/get-started/limits) says when these codes can occur today. - **A `401` after a `500`** is about the key; a `500` alone is not. Never discard a key because of a `500`. ## Errors outside the envelope Every `/api/v1` error uses the body above. An error from outside `/api/v1`, such as a request for a path that does not exist, is not an API error and does not have that body. Check the status code before you read `error.code`. --- # Pagination > Page through collections with limit and an opaque cursor. Four endpoints return collections: [ledger entries](https://www.vestiarion.xyz/docs/api/list-ledger-entries), [invoices](https://www.vestiarion.xyz/docs/api/list-invoices), [counterparties](https://www.vestiarion.xyz/docs/api/list-counterparties) and [milestones](https://www.vestiarion.xyz/docs/api/list-milestones). Each returns one page at a time, and you ask for the next page with a cursor. ## Parameters | Parameter | Meaning | | --- | --- | | `limit` | How many items to return: a positive integer. Defaults to 50; a larger value than 200 is capped at 200. `0`, a negative number or a non-integer answers `400 invalid_request`. | | `cursor` | The previous response's `page.nextCursor`, passed back unchanged. | ## The page object A collection's response carries its items in `data` and describes the page in `page`. This is the `page` of the ledger response in the [Quickstart](https://www.vestiarion.xyz/docs/get-started/quickstart#3-read-the-ledger): ```json {"nextCursor":"eyJrIjo4Mn0","hasMore":true,"count":1} ``` - `count` is how many items this response carries. - `hasMore` is `true` when there is at least one more item after this page. Vestiarion reads one row more than `limit` to know, so it is a fact, not a guess. - `nextCursor` is the cursor for the next page, or `null` when `hasMore` is `false`. ## Cursors are opaque Pass a cursor back exactly as you received it. Do not decode it, build one, or change it: its contents can change without notice. A cursor this endpoint could not have issued, such as a malformed one or a ledger cursor sent to `/invoices`, answers `400 invalid_request`; the API never quietly starts again from the beginning. A cursor continues the request that produced it. Send it to the same endpoint, with the same filters. To change a filter, start again without a cursor. ## Order - **Invoices, counterparties and milestones** are newest first, by creation time, with the row id breaking a tie. - **The ledger** is oldest first, ascending by `seq`, because it is an append-only stream. ## The ledger cursor is a watermark Because the ledger only grows at the end, a ledger cursor marks a position you can come back to: 1. Read pages until `hasMore` is `false`. 2. Store the last non-null `nextCursor`, but only after you have processed every entry in the responses you read. 3. On your next poll, pass the stored cursor. You get every entry after it: the entries of the last page you read, which had no `nextCursor` of its own, and then everything appended since. No entry before the cursor is ever replayed. 4. Skip any entry whose `seq` you have already stored. Keying your copy by `seq` does this for you. `seq` rises within a workspace, but it has gaps, so do not use a gap to detect a missing entry. The hash chain proves continuity: each entry's `prevHash` is the previous entry's `hash`. The [list ledger entries reference](https://www.vestiarion.xyz/docs/api/list-ledger-entries#notes) has the details. ## A paging loop in JavaScript This reads every payable invoice, one page of 200 at a time. It runs on Node 18 or later, which has `fetch`: ```js const BASE = "https://www.vestiarion.xyz/api/v1"; async function listAll(path, params = {}) { const items = []; let cursor = null; do { const query = new URLSearchParams({ ...params, limit: "200" }); if (cursor) query.set("cursor", cursor); const response = await fetch(`${BASE}${path}?${query}`, { headers: { Authorization: `Bearer ${process.env.VESTIARION_API_KEY}` }, }); if (!response.ok) { // Read the body as text: an error from outside /api/v1 has no JSON error body. throw new Error(`${response.status}: ${(await response.text()).slice(0, 200)}`); } const body = await response.json(); items.push(...body.data); cursor = body.page.nextCursor; } while (cursor); return items; } const payables = await listAll("/invoices", { direction: "payable" }); console.log(`${payables.length} payable invoices`); ``` To follow the ledger, keep the last non-null `nextCursor` after each page instead of collecting the items, and start the next poll from it. Never replace it with the final page's `null`: a poll without a cursor starts again from the first entry. --- # Limits > Page sizes, backing off on 429 and 503, and how fresh the data is. What the API bounds today, what it does not, and how fresh its data is. ## Page size A collection returns 1 to 200 items per page. `limit` defaults to 50, and a larger value than 200 is capped at 200 rather than refused. Every page is bounded, so no single request reads a whole ledger. [Pagination](https://www.vestiarion.xyz/docs/get-started/pagination) covers the rest. ## Rate limits Reads have no rate limit. Writes are limited to 30 a minute for each key, counted on each server instance, so a burst can now and then get a little past it. Over the limit, a write answers `429 rate_limited` with `Retry-After: 60`. No endpoint returns `503` today, but `unavailable` is a defined [error code](https://www.vestiarion.xyz/docs/get-started/errors): write your client to back off on both, and it keeps working if a limit is added to reads. - On a `429`, wait at least as long as the `Retry-After` header says, in seconds, before you try again. - On a `503`, retry with exponential backoff. Poll no more often than you need to. To react to each new ledger entry, [webhooks](https://www.vestiarion.xyz/docs/webhooks) push it to you, so you do not need to poll the ledger quickly. ## Request bodies A write's body is one JSON object of at most 64 KB. A body that is larger, is not JSON, or has a field the operation does not take answers `400 invalid_request`, and nothing is written. To make a retry safe, send an `Idempotency-Key`: [Retrying safely](https://www.vestiarion.xyz/docs/guides/api-invoices#retrying-safely) explains it. A payee link takes none: a repeat makes a new link, which replaces the first. ## Keys and endpoints per workspace | Resource | Limit | | --- | --- | | Active API keys | 20 per workspace. Revoke one to create another. | | Active webhook endpoints | 5 per workspace. | ## Freshness Data is read live on each request: responses are not cached, so an invoice, a decision or a payment is visible on the next request after it is recorded. Two endpoints report on the latest cycle rather than on this moment: - [Treasury](https://www.vestiarion.xyz/docs/api/get-treasury)'s `obligations` come from the latest completed cycle snapshot, and are `null` until one exists. - [Insights](https://www.vestiarion.xyz/docs/api/get-insights) reports the telemetry of recent cycles, transfers and screenings. ## Response shapes Every endpoint answers with the documented envelope: `data`, plus `page` for a collection, or `error`. New fields can be added to v1 responses, so ignore fields you do not know rather than rejecting the response. A field is not removed from, or changed in, v1 without an entry in the [changelog](https://www.vestiarion.xyz/docs/changelog). --- # Endpoint overview > Every v1 endpoint, what it answers, and the conventions they share. Every v1 endpoint is under `https://www.vestiarion.xyz/api/v1`, authenticated with a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) sent as `Authorization: Bearer `, and reaches only that key's workspace. A `GET` reads. A `POST` adds a counterparty, an invoice or a milestone, or makes a payee link, with a key that has [write access](https://www.vestiarion.xyz/docs/get-started/authentication#scopes), and answers `201` with the record: shaped as its list returns it, or for a payee link, the link. A response carries its result in `data`: one object, or for a collection an array, beside `page` with `nextCursor`, `hasMore` and `count` for [paging](https://www.vestiarion.xyz/docs/get-started/pagination). A failure answers `{ "error": { "code", "message" } }` with one of the [error codes](https://www.vestiarion.xyz/docs/get-started/errors). The same endpoints are published as an [OpenAPI 3.1 document](https://www.vestiarion.xyz/api/v1/openapi.json). In TypeScript, the [TypeScript SDK](https://www.vestiarion.xyz/docs/get-started/sdk) gives each one a typed method. **Workspace** | Endpoint | Summary | | --- | --- | | [`GET /api/v1/status`](https://www.vestiarion.xyz/docs/api/get-status) | Get workspace status | **Ledger** | Endpoint | Summary | | --- | --- | | [`GET /api/v1/ledger`](https://www.vestiarion.xyz/docs/api/list-ledger-entries) | List ledger entries | | [`GET /api/v1/ledger/verify`](https://www.vestiarion.xyz/docs/api/verify-ledger) | Verify the ledger | **Payables and receivables** | Endpoint | Summary | | --- | --- | | [`GET /api/v1/invoices`](https://www.vestiarion.xyz/docs/api/list-invoices) | List invoices | | [`POST /api/v1/invoices`](https://www.vestiarion.xyz/docs/api/create-invoice) | Add an invoice | **Counterparties** | Endpoint | Summary | | --- | --- | | [`GET /api/v1/counterparties`](https://www.vestiarion.xyz/docs/api/list-counterparties) | List counterparties | | [`GET /api/v1/counterparties/{id}`](https://www.vestiarion.xyz/docs/api/get-counterparty) | Get a counterparty | | [`POST /api/v1/counterparties`](https://www.vestiarion.xyz/docs/api/create-counterparty) | Add a counterparty | | [`POST /api/v1/payee-links`](https://www.vestiarion.xyz/docs/api/create-payee-link) | Create a payee link | **Milestones** | Endpoint | Summary | | --- | --- | | [`GET /api/v1/milestones`](https://www.vestiarion.xyz/docs/api/list-milestones) | List milestones | | [`POST /api/v1/milestones`](https://www.vestiarion.xyz/docs/api/create-milestone) | Add a milestone | **Treasury** | Endpoint | Summary | | --- | --- | | [`GET /api/v1/treasury`](https://www.vestiarion.xyz/docs/api/get-treasury) | Get the treasury | **Insights** | Endpoint | Summary | | --- | --- | | [`GET /api/v1/insights`](https://www.vestiarion.xyz/docs/api/get-insights) | Get insights | --- # Get workspace status `GET /api/v1/status` What this workspace is, and what it can actually do. The first call a client should make: whether payments and yield are live, simulated or unavailable, the workspace clock, running totals, and a description of its configuration. Secrets are never included. `unavailable` means the workspace's Circle credentials are stored but cannot be read. A cycle refuses to pay in that state rather than fall back to simulation, so status does not report it as `simulate`. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) as `Authorization: Bearer `. ## Parameters No parameters. ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/status" \ -H "Authorization: Bearer $VESTIARION_API_KEY" ``` ## Response Example, `200` `application/json`: ```json { "data": { "businessName": "Vestiarion workspace", "provenance": { "payments": "live", "yield": "simulate", "screening": "simulate" }, "clock": { "mode": "simulate", "day": 25, "lastCycleAt": "2026-09-24T18:33:04.546517+00:00" }, "totals": { "decisionsLogged": 77, "totalPaidOut": 4.815, "flagged": 1 }, "configuration": { "businessName": "Vestiarion workspace", "chain": { "circleConfigured": true, "arcRpcConfigured": false }, "llm": { "pinned": null, "available": [ "deepseek" ] }, "compliance": { "mode": "bundled", "rescreenIntervalHours": 0 }, "followUp": { "staleAfterDays": 3, "reEscalateAfterDays": 7 }, "ledgerSigningKeyProvided": false, "githubTokenProvided": false, "clockMode": "simulate" }, "apiVersion": "v1" } } ``` **Fields** - `data` (object, required): What this workspace is, and what it can actually do. - `businessName` (string, required): The workspace's name. - `provenance` (object, required): Payments and yield differ and are reported separately, as in the UI. `unavailable` means the workspace's Circle credentials are stored but could not be read: cycles refuse to pay then rather than simulate, so neither leg is live or simulated. - `payments` (string, required) One of `live`, `simulate`, `unavailable`. - `yield` (string, required) One of `live`, `simulate`, `unavailable`. - `screening` (string, required) One of `live`, `simulate`. - `clock` (object, required): The workspace's clock, and when its last cycle ran. - `mode` (string, required) One of `real`, `simulate`. - `day` (number, required) - `lastCycleAt` (string, nullable, required) - `totals` (object, required): Running totals across the workspace. - `decisionsLogged` (number, required) - `totalPaidOut` (number, required) - `flagged` (number, required) - `configuration` (object, required): What is configured for this workspace, as flags and modes. Never carries a secret. - `apiVersion` (string, required) One of `v1`. ## Errors | Status | Code | When | | --- | --- | --- | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | ## Notes ### Provenance `provenance` says what the workspace's actions are backed by, per leg: | Field | Values | Meaning | | --- | --- | --- | | `payments` | `live`, `simulate`, `unavailable` | Whether invoice and milestone payments settle on chain through Circle, or are simulated. | | `yield` | `live`, `simulate`, `unavailable` | The same, for moves to and from the yield reserve. | | `screening` | `live`, `simulate` | Whether this workspace's counterparties are screened against a live sanctions source (OpenSanctions) or the bundled, simulated watchlist. A sandbox always uses the bundled watchlist. | `unavailable` means the workspace's Circle credentials are stored but cannot be read, for example because they are sealed under a master key this deployment does not hold. A cycle refuses to pay in that state rather than fall back to simulation, so status does not report it as `simulate`. Check `provenance.payments` before you treat a `paid` invoice as money that moved. ### Configuration `configuration` describes the workspace's setup as flags and modes, and never carries a secret. Treat it as informational: its keys can grow. Three fields describe the ledger signing key; the example above was captured before the last two were added: - `ledgerSigningKeyProvided`: whether the workspace's stored signing key could be read. - `ledgerPublicKeyProvided`: always `false` inside a workspace, because the public half is derived from the stored signing key rather than configured on its own. - `ledgerRetiredKeyCount`: how many earlier public keys the deployment still accepts when [verifying the ledger](https://www.vestiarion.xyz/docs/api/verify-ledger#key-identity-and-rotation). --- # List ledger entries `GET /api/v1/ledger` The audit chain, oldest first, as a resumable stream. Because the ledger is append-only and ascending by `seq`, a stored `page.nextCursor` is a watermark: a request from it never returns an entry before it. Persist the last non-null `nextCursor` only after processing every entry in the responses read, and resume from it; the last page, which had no cursor of its own, is returned again, so de-duplicate on `seq`. `seq` is monotonic within a workspace but not gap-free; continuity is proven by the hash chain, not by `seq`. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) as `Authorization: Bearer `. ## Parameters | Name | In | Type | Required | Default | Allowed values | Description | | --- | --- | --- | --- | --- | --- | --- | | `limit` | query | integer | Optional | `50` | 1 to 200 | How many items to return. Defaults to 50; a larger value is capped at 200. | | `cursor` | query | string | Optional | — | — | The previous response's `page.nextCursor`, passed back unchanged to continue. Opaque: never decode or construct one. A cursor this endpoint could not have issued is refused with `400`. | | `domain` | query | string | Optional | — | — | Only entries in this domain. | | `actor` | query | string | Optional | — | — | Only entries written by this actor. | ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/ledger" \ -H "Authorization: Bearer $VESTIARION_API_KEY" ``` ## Response Example, `200` `application/json`: ```json { "data": [ { "seq": 82, "id": "4566714f-a9f0-42c8-bcd4-d89adf830806", "ts": "2026-09-24T11:55:00.370366+00:00", "actor": "system", "domain": "system", "action": "seed", "summary": "Seeded demo business: Northstar Studio", "detail": { "accounts": 3, "invoices": 6, "milestones": 3, "amountScale": 0.001, "counterparties": 7 }, "bodyHash": "8780d07cb3d083d359119e58fd4783f5e4b32aac14e0a8d9f7a5bb13365a4537", "signature": "c14f06b751d31be6566ed11676d60d2db731ab8bbc5972f79d0a0c131e9e37200303d5b3ad04993b7973b1ce7200e7214e5a9b511306fa30538f072eb0d34705", "prevHash": "0000000000000000000000000000000000000000000000000000000000000000", "hash": "b0ac72908868ddd5ed4722fd8a14ff4a844eee4dad382c5c85288bca73737669", "signingKeyId": null } ], "page": { "nextCursor": "eyJrIjo4Mn0", "hasMore": true, "count": 1 } } ``` **Fields** - `data` (array of object, required) - `seq` (number, required): Monotonic within a workspace, but not gap-free; the hash chain proves continuity. - `id` (string, required) - `ts` (string, required) - `actor` (string, required) - `domain` (string, required) - `action` (string, required) - `summary` (string, required) - `detail` (object, required) - `bodyHash` (string, required): Present so a consumer can verify the chain itself rather than trust us. - `signature` (string, required) - `prevHash` (string, required) - `hash` (string, required) - `signingKeyId` (string, nullable, required): Which key signed the entry; `null` for entries written before key identity existed. A consumer verifying for itself needs this to pick the right key. - `page` (object, required): Where this page sits in the collection. - `nextCursor` (string, nullable, required): Pass back as `?cursor=` to continue. Null when the end is reached. - `hasMore` (boolean, required): Whether another page follows this one. - `count` (integer, required): How many items this response carries. ## Errors | Status | Code | When | | --- | --- | --- | | 400 | `invalid_request` | An invalid `limit` or `cursor`, a filter value outside its allowed values, or a request body that does not validate. The message names the parameter or the field, and lists the accepted values. | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | ## Notes ### Resuming from a cursor Persist `page.nextCursor` only after processing every entry in that response, and pass it back unchanged: ```text GET /api/v1/ledger?limit=100 GET /api/v1/ledger?limit=100&cursor= ``` Continue until `hasMore` is `false` and `nextCursor` is `null`. On the next poll, reuse the last non-null cursor you processed: it returns the entries after it, starting with the last page you read, then everything appended since, and never an entry before it. Store entries keyed by `seq` and skip the ones you already have. [Pagination](https://www.vestiarion.xyz/docs/get-started/pagination#the-ledger-cursor-is-a-watermark) walks through it. A malformed cursor answers `400 invalid_request`. The API never silently restarts from the beginning. ### `seq` and the hash chain `seq` is a global identity column, so within one workspace it rises but is not gap-free. Continuity is proven by the hash chain, not by `seq` running without gaps: each entry's `prevHash` is the `hash` of the entry before it, and the first entry's `prevHash` is 64 zeros. [Verify the ledger](https://www.vestiarion.xyz/docs/api/verify-ledger) replays the chain and the signatures for you. ### Filters `domain` and `actor` match exactly. Unlike the enumerated filters on other endpoints, they accept any value: a value no entry has returns an empty page, not an error. ### `signingKeyId` Each entry names the key that signed it in `signingKeyId`: the first 16 hex characters of SHA-256 over the signing key's SPKI DER. Older entries carry `null`. The label is outside both the body hash and the chain hash: it selects which key to check against, and proves nothing by itself. [Verifying a ledger entry's signature](https://www.vestiarion.xyz/docs/webhooks/verify#verifying-a-ledger-entry-s-ed25519-signature) shows the check. --- # Verify the ledger `GET /api/v1/ledger/verify` Replays signatures, body hashes and hash-chain continuity for the workspace the calling key belongs to. `valid` has three values, not two. `true` verified and `false` broken are findings about the chain; `null` means no verdict was produced, because there was no key to check authorship against. `reason` says which case it is. A key that is stored but cannot be read is a configuration problem, reported in `warnings`, not a finding about the chain. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) as `Authorization: Bearer `. ## Parameters No parameters. ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/ledger/verify" \ -H "Authorization: Bearer $VESTIARION_API_KEY" ``` ## Response Example, `200` `application/json`: ```json { "data": { "valid": true, "checkedEntries": 99 } } ``` **Fields** - `data` (object, required): The verdict of replaying the workspace's ledger: signatures, body hashes and hash continuity. - `valid` (boolean, nullable, required): `true` verified, `false` broken, and `null` not checked, which is a third answer, not a soft failure. A workspace holding no public key has produced no evidence either way. - `checkedEntries` (number, required) - `brokenAt` (number, optional): The `seq` of the first entry that failed. Absent unless `valid` is `false`. - `reason` (string, optional): Why the verdict is what it is, when it is not a plain `true`. - `warnings` (array of string, optional): Configuration problems found on the way to this verdict; not about the chain. ## Errors | Status | Code | When | | --- | --- | --- | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | ## Notes ### `valid` has three values `true` (verified) and `false` (broken) are findings about the chain. **`null` means no verdict was produced**: the workspace has no ledger public key to check against, so authorship was never checked. A consumer that treats `null` as a failure will report a tampered audit trail when a key was never set up for the workspace. `reason` says which case it is, and `brokenAt` is absent whenever `valid` is `null`. ```json {"data":{"valid":null,"checkedEntries":99,"reason":"no ledger public key is configured, so authorship was not checked"}} ``` ### A key that cannot be read A key that is stored but cannot be read, such as a PEM whose newlines were lost, is a **configuration problem, not a finding about the chain**, and the two are kept apart. Verification never fails over a bad key: it verifies with whatever it could read, and reports what it could not in `warnings`: ```json {"data":{"valid":true,"checkedEntries":105,"warnings":["The ledger signing key is not a readable private key: error:1E08010C:DECODER routines::unsupported"]}} ``` When a broken key is the reason no key is available at all, `reason` names it, instead of saying "no ledger public key is configured". ### Key identity and rotation Every entry written since signing key ids were introduced carries `signingKeyId`; older entries carry `null`. Verification accepts a **keyring**: the workspace's current key plus the earlier public keys the deployment still accepts. A labeled entry is checked against the key it names, and an unlabeled one against any key in the ring. So a rotated key keeps the history it signed verifiable, and a label the ring does not know is reported as its own case, again with `valid: null`: ```json {"data":{"valid":null,"checkedEntries":112,"reason":"entry #104 was signed by key 4f2a9c1e88b30d57, which is not in this deployment's keyring (a71b0e6640cc2f93), so its authorship was not checked"}} ``` That message names the missing key and the ones the deployment has: the difference between a suspected forgery and a retired key that was never added to the keyring. A rotation is recorded in the ledger itself, as a `system` entry with `action: "ledger_key_rotated"`, signed by the new key. When an owner rotates the key from Settings, the entry has `actor: "human"` and its `detail` is `{"from": "", "to": "", "by": ""}`, where `by` is the owner who rotated. When the key changed any other way, the first append under the new key writes the entry automatically, with `actor: "system"` and `detail` `{"from": "", "to": ""}`. Either way, a consumer following the ledger sees the change of authority as an entry. From that entry on, `signingKeyId` is the new key's id. Earlier entries keep the old id, and keep verifying against the retired key, which stays in the workspace's keyring. ### The public key This endpoint reports whether the chain checks out; it does not return the public key. To check signatures yourself, take the PEM and its key id from the workspace's Audit page, `/o//audit`, under "Ledger signing public key". [Verifying a ledger entry's signature](https://www.vestiarion.xyz/docs/webhooks/verify#verifying-a-ledger-entry-s-ed25519-signature) shows how. The Audit page itself calls a separate route, `GET /api/ledger/verify?org=`, which takes a signed-in member's session rather than an API key and answers in a legacy bare shape. It is not part of the v1 API; integrations use this endpoint. --- # List invoices `GET /api/v1/invoices` The payable and receivable book, newest first, with the row id breaking equal timestamps. Each invoice carries the agent's reasoning, not only its verdict. Only a transaction reference beginning with `0x` is exposed as `txHash`; a simulated receipt gives `null`. An unknown `direction` or `status` is refused with `400` rather than ignored. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) as `Authorization: Bearer `. ## Parameters | Name | In | Type | Required | Default | Allowed values | Description | | --- | --- | --- | --- | --- | --- | --- | | `limit` | query | integer | Optional | `50` | 1 to 200 | How many items to return. Defaults to 50; a larger value is capped at 200. | | `cursor` | query | string | Optional | — | — | The previous response's `page.nextCursor`, passed back unchanged to continue. Opaque: never decode or construct one. A cursor this endpoint could not have issued is refused with `400`. | | `direction` | query | string | Optional | — | `payable`, `receivable` | Only payables, or only receivables. | | `status` | query | string | Optional | — | `pending`, `matched`, `scheduled`, `paid`, `held`, `flagged`, `awaiting_info`, `received`, `rejected` | Only invoices in this status. | | `counterpartyId` | query | string | Optional | — | — | Only invoices from or to this counterparty. | ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/invoices" \ -H "Authorization: Bearer $VESTIARION_API_KEY" ``` ## Response Example, `200` `application/json`: ```json { "data": [ { "id": "9440f32c-000d-4a63-97f1-4eb6bf78439f", "direction": "payable", "status": "paid", "amount": 0.11, "currency": "USDC", "memo": "Stage-isolation verification", "poReference": "PO-4001", "goodsReceived": true, "dueDate": "2026-09-28T15:22:48.928+00:00", "scheduledFor": null, "earlyPayDiscount": null, "decidedAt": "2026-09-24T15:24:22.75+00:00", "settledAt": "2026-09-24T15:24:22.75+00:00", "escalatedAt": null, "agentReasoning": "Three-way match is complete: PO-4001 is on file and goodsReceived is true. Counterparty Vercel Inc has riskLevel 'clear' (not high) and the invoice amount 0.11 USDC is below the counterparty payment limit of 2 USDC. Treasury operatingBalance is 22.875 USDC, leaving 22.765 USDC after payment, and no duplicate matches were found (duplicateNote confirms no earlier payable from this counterparty resembles this invoice), so there is no fraud indicator.", "txHash": "0xda97ba74aca6a45e4759858230e743aac0735252b874fb07c20ec8026d80a7bf", "paidAmount": 0.11, "counterparty": { "id": "4e363b59-d1ca-4425-924c-5c894bc3373f", "name": "Vercel Inc", "riskLevel": "clear" }, "createdAt": "2026-09-24T15:22:50.176754+00:00" } ], "page": { "nextCursor": "eyJrIjoiMjAyNi0wOS0yNFQxNToyMjo1MC4xNzY3NTQrMDA6MDAiLCJpZCI6Ijk0NDBmMzJjLTAwMGQtNGE2My05N2YxLTRlYjZiZjc4NDM5ZiJ9", "hasMore": true, "count": 1 } } ``` **Fields** - `data` (array of object, required) - `id` (string, required) - `direction` (string, required) One of `payable`, `receivable`. - `status` (string, required) - `amount` (number, required) - `currency` (string, required): USDC or EURC: what `amount` is in, and what a payable is paid in. A EURC payable is checked against the counterparty's USDC limit at a quoted rate. - `memo` (string, nullable, required) - `poReference` (string, nullable, required) - `goodsReceived` (boolean, required) - `dueDate` (string, required) - `scheduledFor` (string, nullable, required): ISO timestamp the agent has committed to pay this on, once scheduled; else null. - `earlyPayDiscount` (object, nullable, required): The early-payment discount this invoice carries, if any: the percent off and the deadline's ISO timestamp. - `percent` (number, required) - `deadline` (string, required) - `decidedAt` (string, nullable, required) - `settledAt` (string, nullable, required) - `escalatedAt` (string, nullable, required) - `agentReasoning` (string, nullable, required): Why the agent ruled as it did, verbatim from the decision. - `txHash` (string, nullable, required): An on-chain hash once the payment settled, else null: on Arc testnet, or for a payout from a Gateway balance the mint on the payee's chain. - `paidAmount` (number, nullable, required): What actually left once this invoice was paid; null otherwise, even while a submitted transfer already carries an amount. - `counterparty` (object, nullable, required) - `id` (string, required) - `name` (string, required) - `riskLevel` (string, required) - `createdAt` (string, required) - `page` (object, required): Where this page sits in the collection. - `nextCursor` (string, nullable, required): Pass back as `?cursor=` to continue. Null when the end is reached. - `hasMore` (boolean, required): Whether another page follows this one. - `count` (integer, required): How many items this response carries. ## Errors | Status | Code | When | | --- | --- | --- | | 400 | `invalid_request` | An invalid `limit` or `cursor`, a filter value outside its allowed values, or a request body that does not validate. The message names the parameter or the field, and lists the accepted values. | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | --- # Add an invoice `POST /api/v1/invoices` Adds a payable or a receivable, checked by the rules of the console's invoice form, and recorded in the ledger as `create_invoice` with `via: "api"` and the key's id. It is added as the key's issuer's: if the agent holds it, the issuer cannot approve it, unless they are the workspace's only approver. The agent decides a payable as one typed in, with every guardrail and the workspace's limits, usually within a minute. The API never approves or pays. A `counterpartyId` the workspace does not hold, including another workspace's, answers `400`. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) with read and write access as `Authorization: Bearer `. A read-only key gets `403`. ## Parameters | Name | In | Type | Required | Default | Allowed values | Description | | --- | --- | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | Optional | — | — | Makes a retry safe: up to 255 printable ASCII characters, unique to the record being added, such as its id in your own system. A repeat with the same key and the same body within 24 hours gets the first answer back, with `Idempotent-Replayed: true`, and adds nothing. The same key with a different body answers `409`. | ## Request body Example, `application/json`: ```json { "counterpartyId": "6b361405-cfda-4400-a286-364b561911ce", "amount": "0.10", "dueDate": "2026-10-03", "poReference": "PO-API-1", "goodsReceived": true } ``` **Fields** - `direction` (string, optional): `payable`, a bill the business pays, which is the default, or `receivable`, one it is owed. One of `payable`, `receivable`. - `counterpartyId` (string, required): The counterparty's `id`, from `GET /api/v1/counterparties` or from the answer that added it. - `amount` (string | number, required): What it bills, in `currency`, with at most 6 decimal places. A decimal string such as `"1250.50"` keeps it exact; a number is read the same way. - `currency` (string, optional): `USDC`, the default, or `EURC`. One of `USDC`, `EURC`. - `dueDate` (string, required): The day it is due, as `YYYY-MM-DD`. - `memo` (string, optional): What it is for, up to 280 characters. - `poReference` (string, optional): The purchase order it bills against, up to 100 characters. - `goodsReceived` (boolean, optional): Whether what it bills for has arrived. Defaults to `false`. Without it, or without `poReference`, the agent asks for the missing detail instead of paying. - `earlyPayDiscount` (object, optional): A discount for paying by `deadline`. The agent weighs it against what the cash would earn in the reserve until `dueDate`. - `percent` (string | number, required): The percent off, greater than 0 and less than 100, with at most 2 decimal places. - `deadline` (string, required): The last day it applies, as `YYYY-MM-DD`, on or before `dueDate`. ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/invoices" \ -X POST \ -H "Authorization: Bearer $VESTIARION_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: billing-inv-2026-0042" \ -d '{"counterpartyId":"6b361405-cfda-4400-a286-364b561911ce","amount":"0.10","dueDate":"2026-10-03","poReference":"PO-API-1","goodsReceived":true}' ``` ## Response Example, `201` `application/json`: ```json { "data": { "id": "1f96fd0b-71de-41bf-b088-779655ea6df4", "direction": "payable", "status": "pending", "amount": 0.1, "currency": "USDC", "memo": null, "poReference": "PO-API-1", "goodsReceived": true, "dueDate": "2026-10-03T12:00:00+00:00", "scheduledFor": null, "earlyPayDiscount": null, "decidedAt": null, "settledAt": null, "escalatedAt": null, "agentReasoning": null, "txHash": null, "paidAmount": null, "counterparty": { "id": "6b361405-cfda-4400-a286-364b561911ce", "name": "API Test Vendor", "riskLevel": "clear" }, "createdAt": "2026-10-03T10:16:16.374931+00:00" } } ``` **Fields** - `data` (object, required): An invoice in the payable or receivable book, with the agent's reasoning. - `id` (string, required) - `direction` (string, required) One of `payable`, `receivable`. - `status` (string, required) - `amount` (number, required) - `currency` (string, required): USDC or EURC: what `amount` is in, and what a payable is paid in. A EURC payable is checked against the counterparty's USDC limit at a quoted rate. - `memo` (string, nullable, required) - `poReference` (string, nullable, required) - `goodsReceived` (boolean, required) - `dueDate` (string, required) - `scheduledFor` (string, nullable, required): ISO timestamp the agent has committed to pay this on, once scheduled; else null. - `earlyPayDiscount` (object, nullable, required): The early-payment discount this invoice carries, if any: the percent off and the deadline's ISO timestamp. - `percent` (number, required) - `deadline` (string, required) - `decidedAt` (string, nullable, required) - `settledAt` (string, nullable, required) - `escalatedAt` (string, nullable, required) - `agentReasoning` (string, nullable, required): Why the agent ruled as it did, verbatim from the decision. - `txHash` (string, nullable, required): An on-chain hash once the payment settled, else null: on Arc testnet, or for a payout from a Gateway balance the mint on the payee's chain. - `paidAmount` (number, nullable, required): What actually left once this invoice was paid; null otherwise, even while a submitted transfer already carries an amount. - `counterparty` (object, nullable, required) - `id` (string, required) - `name` (string, required) - `riskLevel` (string, required) - `createdAt` (string, required) ## Errors | Status | Code | When | | --- | --- | --- | | 400 | `invalid_request` | An invalid `limit` or `cursor`, a filter value outside its allowed values, or a request body that does not validate. The message names the parameter or the field, and lists the accepted values. | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 409 | `conflict` | The `Idempotency-Key` was already used for a different request, or the first request with it is still being handled. | | 429 | `rate_limited` | Too many requests; wait as long as `Retry-After` says before trying again. | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | --- # List counterparties `GET /api/v1/counterparties` Vendors, clients and contractors, newest first, with the row id breaking equal timestamps. Both the business's baseline payment limit and the current limit derived from the risk tier are reported. `performanceScore` is `null` when there is no history. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) as `Authorization: Bearer `. ## Parameters | Name | In | Type | Required | Default | Allowed values | Description | | --- | --- | --- | --- | --- | --- | --- | | `limit` | query | integer | Optional | `50` | 1 to 200 | How many items to return. Defaults to 50; a larger value is capped at 200. | | `cursor` | query | string | Optional | — | — | The previous response's `page.nextCursor`, passed back unchanged to continue. Opaque: never decode or construct one. A cursor this endpoint could not have issued is refused with `400`. | | `role` | query | string | Optional | — | `vendor`, `client`, `contractor` | Only counterparties with this role. | | `riskLevel` | query | string | Optional | — | `unscreened`, `clear`, `medium`, `high` | Only counterparties at this risk tier. | ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/counterparties" \ -H "Authorization: Bearer $VESTIARION_API_KEY" ``` ## Response Example, `200` `application/json`: ```json { "data": [ { "id": "dc5e5751-3287-46c9-8bd1-83a42ab02699", "name": "Anthropic API Services", "role": "vendor", "address": "0x90a5821e8a59b711777c49d11a283c9c76cd811e", "chain": "ARC-TESTNET", "jurisdiction": null, "riskLevel": "clear", "riskNotes": "No match against watchlist", "baselinePaymentLimit": 5, "paymentLimit": 5, "lastScreenedAt": "2026-09-24T18:32:57.327+00:00", "performanceScore": 0.667, "performanceInputs": { "heldOrFlagged": 0, "heldByOurPolicy": 1, "riskTierChanges": 0, "duplicateSubmissions": 0, "informationRequested": 0, "paidWithoutIntervention": 1 }, "createdAt": "2026-09-24T11:54:57.677284+00:00" } ], "page": { "nextCursor": "eyJrIjoiMjAyNi0wOS0yNFQxMTo1NDo1Ny42NzcyODQrMDA6MDAiLCJpZCI6ImRjNWU1NzUxLTMyODctNDZjOS04YmQxLTgzYTQyYWIwMjY5OSJ9", "hasMore": true, "count": 1 } } ``` **Fields** - `data` (array of object, required) - `id` (string, required) - `name` (string, required) - `role` (string, required) One of `vendor`, `client`, `contractor`. - `address` (string, nullable, required) - `chain` (string, nullable, required) - `jurisdiction` (string, nullable, required) - `riskLevel` (string, required) One of `unscreened`, `clear`, `medium`, `high`. - `riskNotes` (string, nullable, required) - `baselinePaymentLimit` (number, nullable, required): The business's baseline payment limit for this counterparty. - `paymentLimit` (number, nullable, required): The current payment limit, derived from the risk tier. - `lastScreenedAt` (string, nullable, required) - `performanceScore` (number, nullable, required): No history is different from a zero score and remains null. - `performanceInputs` (object, nullable, required) - `createdAt` (string, required) - `page` (object, required): Where this page sits in the collection. - `nextCursor` (string, nullable, required): Pass back as `?cursor=` to continue. Null when the end is reached. - `hasMore` (boolean, required): Whether another page follows this one. - `count` (integer, required): How many items this response carries. ## Errors | Status | Code | When | | --- | --- | --- | | 400 | `invalid_request` | An invalid `limit` or `cursor`, a filter value outside its allowed values, or a request body that does not validate. The message names the parameter or the field, and lists the accepted values. | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | --- # Get a counterparty `GET /api/v1/counterparties/{id}` One counterparty, with up to 20 recent compliance screenings, newest first. An id this workspace does not hold, including another workspace's, answers `404`. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) as `Authorization: Bearer `. ## Parameters | Name | In | Type | Required | Default | Allowed values | Description | | --- | --- | --- | --- | --- | --- | --- | | `id` | path | string | Required | — | — | The counterparty's id. | ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/counterparties/" \ -H "Authorization: Bearer $VESTIARION_API_KEY" ``` ## Response Example, `200` `application/json`: ```json { "data": { "id": "dc5e5751-3287-46c9-8bd1-83a42ab02699", "name": "Anthropic API Services", "role": "vendor", "address": "0x90a5821e8a59b711777c49d11a283c9c76cd811e", "chain": "ARC-TESTNET", "jurisdiction": null, "riskLevel": "clear", "riskNotes": "No match against watchlist", "baselinePaymentLimit": 5, "paymentLimit": 5, "lastScreenedAt": "2026-09-24T18:32:57.327+00:00", "performanceScore": 0.667, "performanceInputs": { "heldOrFlagged": 0, "heldByOurPolicy": 1, "riskTierChanges": 0, "duplicateSubmissions": 0, "informationRequested": 0, "paidWithoutIntervention": 1 }, "createdAt": "2026-09-24T11:54:57.677284+00:00", "screeningHistory": [ { "id": "d49b558b-199f-46d5-a43e-ae2af3ad9c49", "riskLevel": "clear", "source": "simulated-sanctions-list", "notes": "No match against watchlist", "rawScore": null, "matchedEntityId": null, "screeningMode": "simulate", "status": "complete", "createdAt": "2026-09-24T18:32:58.403732+00:00" } ] } } ``` **Fields** - `data` (object, required): A counterparty, with its recent screening history. - `id` (string, required) - `name` (string, required) - `role` (string, required) One of `vendor`, `client`, `contractor`. - `address` (string, nullable, required) - `chain` (string, nullable, required) - `jurisdiction` (string, nullable, required) - `riskLevel` (string, required) One of `unscreened`, `clear`, `medium`, `high`. - `riskNotes` (string, nullable, required) - `baselinePaymentLimit` (number, nullable, required): The business's baseline payment limit for this counterparty. - `paymentLimit` (number, nullable, required): The current payment limit, derived from the risk tier. - `lastScreenedAt` (string, nullable, required) - `performanceScore` (number, nullable, required): No history is different from a zero score and remains null. - `performanceInputs` (object, nullable, required) - `createdAt` (string, required) - `screeningHistory` (array of object, required): Up to 20 recent screenings, newest first. - `id` (string, required) - `riskLevel` (string, required) - `source` (string, required) - `notes` (string, nullable, required) - `rawScore` (number, nullable, required) - `matchedEntityId` (string, nullable, required) - `screeningMode` (string, required) One of `live`, `simulate`. - `status` (string, required) One of `complete`, `failed`. - `createdAt` (string, required) ## Errors | Status | Code | When | | --- | --- | --- | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 404 | `not_found` | The requested resource does not exist in the key's workspace. | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | --- # Add a counterparty `POST /api/v1/counterparties` Adds a vendor, contractor or client, checked by the rules of the console's form and screened as one added there: the answer's `riskLevel` is the screening's verdict, or `unscreened` when screening could not finish. It is recorded in the ledger as `create_counterparty` with `via: "api"` and the key's id. An address added through the API waits for a person. The agent pays nothing to it until an owner, admin or approver confirms it on Counterparties, so a key can add records but cannot point the agent's payments at a new address. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) with read and write access as `Authorization: Bearer `. A read-only key gets `403`. ## Parameters | Name | In | Type | Required | Default | Allowed values | Description | | --- | --- | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | Optional | — | — | Makes a retry safe: up to 255 printable ASCII characters, unique to the record being added, such as its id in your own system. A repeat with the same key and the same body within 24 hours gets the first answer back, with `Idempotent-Replayed: true`, and adds nothing. The same key with a different body answers `409`. | ## Request body Example, `application/json`: ```json { "name": "API Test Vendor", "role": "vendor", "address": "0x6b3A4C65f362b818bb7FD6f999477A03CE07e51a", "paymentLimit": "1" } ``` **Fields** - `name` (string, required): 2 to 160 characters. - `role` (string, required): `vendor` or `contractor`, whom the business pays, or `client`, who pays the business. One of `vendor`, `client`, `contractor`. - `address` (string, optional): Where the agent pays it. An address added through the API waits for a person in the workspace to confirm it on Counterparties; until then the agent pays nothing to it. - `chain` (string, optional): The chain the address receives on. Defaults to `ARC-TESTNET`; only a vendor can be paid on another chain. One of `ARC-TESTNET`, `BASE-SEPOLIA`, `ARB-SEPOLIA`, `ETH-SEPOLIA`. - `jurisdiction` (string, optional): Where it is based, up to 80 characters; screening uses it. - `paymentLimit` (string | number, optional): The most the agent pays it in one payment, in USDC, with up to 6 decimal places. Required for a vendor or a contractor. - `noticeEmail` (string, optional): Where it is emailed once a payment to it is confirmed. ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/counterparties" \ -X POST \ -H "Authorization: Bearer $VESTIARION_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: crm-vendor-1042" \ -d '{"name":"API Test Vendor","role":"vendor","address":"0x6b3A4C65f362b818bb7FD6f999477A03CE07e51a","paymentLimit":"1"}' ``` ## Response Example, `201` `application/json`: ```json { "data": { "id": "6b361405-cfda-4400-a286-364b561911ce", "name": "API Test Vendor", "role": "vendor", "address": "0x6b3A4C65f362b818bb7FD6f999477A03CE07e51a", "chain": "ARC-TESTNET", "jurisdiction": null, "riskLevel": "clear", "riskNotes": "OpenSanctions returned no matching entity", "baselinePaymentLimit": 1, "paymentLimit": 1, "lastScreenedAt": "2026-10-03T10:15:29.759+00:00", "performanceScore": null, "performanceInputs": null, "createdAt": "2026-10-03T10:15:26.865688+00:00" } } ``` **Fields** - `data` (object, required): A vendor, client or contractor, with its risk tier and payment limits. - `id` (string, required) - `name` (string, required) - `role` (string, required) One of `vendor`, `client`, `contractor`. - `address` (string, nullable, required) - `chain` (string, nullable, required) - `jurisdiction` (string, nullable, required) - `riskLevel` (string, required) One of `unscreened`, `clear`, `medium`, `high`. - `riskNotes` (string, nullable, required) - `baselinePaymentLimit` (number, nullable, required): The business's baseline payment limit for this counterparty. - `paymentLimit` (number, nullable, required): The current payment limit, derived from the risk tier. - `lastScreenedAt` (string, nullable, required) - `performanceScore` (number, nullable, required): No history is different from a zero score and remains null. - `performanceInputs` (object, nullable, required) - `createdAt` (string, required) ## Errors | Status | Code | When | | --- | --- | --- | | 400 | `invalid_request` | An invalid `limit` or `cursor`, a filter value outside its allowed values, or a request body that does not validate. The message names the parameter or the field, and lists the accepted values. | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 409 | `conflict` | The `Idempotency-Key` was already used for a different request, or the first request with it is still being handled. | | 429 | `rate_limited` | Too many requests; wait as long as `Retry-After` says before trying again. | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | --- # Create a payee link `POST /api/v1/payee-links` Makes a one-time link where a vendor or contractor enters the address they are paid at, recorded in the ledger as `payee_link_created` with `via: "api"` and the key's id. Send `url` to the payee yourself. It is in this answer only, since Vestiarion keeps just its hash, and the answer is sent with `Cache-Control: no-store`. The link works once and expires after 7 days. The address the payee enters waits for a person in the workspace to confirm it on Counterparties; until then the agent pays nothing to it. Making a link revokes the payee's unused one, so only the newest works. For the same reason this operation keeps no outcome for an `Idempotency-Key`, which would store the link: a repeat makes a new link. A `counterpartyId` the workspace does not hold, or a client's, answers `400`. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) with read and write access as `Authorization: Bearer `. A read-only key gets `403`. ## Parameters No parameters. ## Request body Example, `application/json`: ```json { "counterpartyId": "26d6ffed-356d-474a-8d42-89bc942f6b4e" } ``` **Fields** - `counterpartyId` (string, required): The `id` of the vendor or contractor who is to enter the address they are paid at. A client gets no link: the agent never pays one. ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/payee-links" \ -X POST \ -H "Authorization: Bearer $VESTIARION_API_KEY" \ -H "Content-Type: application/json" \ -d '{"counterpartyId":"26d6ffed-356d-474a-8d42-89bc942f6b4e"}' ``` ## Response Example, `201` `application/json`: ```json { "data": { "id": "0810c2f3-dc6b-487e-aff5-63297ad33813", "counterpartyId": "26d6ffed-356d-474a-8d42-89bc942f6b4e", "url": "https://www.vestiarion.xyz/payee/vxp_avUyHXYqUlnt1OMB323j4N6Xv3H5lKzjZRNvbm1hfc4", "expiresAt": "2026-10-10T15:42:04.28+00:00" } } ``` **Fields** - `data` (object, required): A one-time link for a payee to enter the address they are paid at. That address waits for a person in the workspace to confirm it before the agent pays to it. - `id` (string, required): The link's own id. It is not the link: that is `url`. - `counterpartyId` (string, required) - `url` (string, required): The one-time page where the payee enters their address. It is in this answer only: Vestiarion keeps just its hash. Send it to the payee yourself. - `expiresAt` (string, required): When the link stops working, 7 days after it was made. It also stops once the payee has used it, or a newer link replaces it. ## Errors | Status | Code | When | | --- | --- | --- | | 400 | `invalid_request` | An invalid `limit` or `cursor`, a filter value outside its allowed values, or a request body that does not validate. The message names the parameter or the field, and lists the accepted values. | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 429 | `rate_limited` | Too many requests; wait as long as `Retry-After` says before trying again. | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | --- # List milestones `GET /api/v1/milestones` Contractor milestones, newest first, with the row id breaking equal timestamps: how each was verified, and whether it was paid. Only a real `0x` transaction is exposed as `txHash`. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) as `Authorization: Bearer `. ## Parameters | Name | In | Type | Required | Default | Allowed values | Description | | --- | --- | --- | --- | --- | --- | --- | | `limit` | query | integer | Optional | `50` | 1 to 200 | How many items to return. Defaults to 50; a larger value is capped at 200. | | `cursor` | query | string | Optional | — | — | The previous response's `page.nextCursor`, passed back unchanged to continue. Opaque: never decode or construct one. A cursor this endpoint could not have issued is refused with `400`. | | `status` | query | string | Optional | — | `pending`, `verified`, `paid`, `held`, `closed` | Only milestones in this status. | | `contractorId` | query | string | Optional | — | — | Only milestones for this contractor. | ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/milestones" \ -H "Authorization: Bearer $VESTIARION_API_KEY" ``` ## Response Example, `200` `application/json`: ```json { "data": [ { "id": "d01b2b49-2ca3-45fe-898c-f2c0128f6188", "title": "Landing page redesign — milestone 2", "amount": 0.9, "status": "paid", "verificationSource": "timesheet:kimai", "verificationMethod": "seed", "verificationStatus": "verified", "verificationCheckedAt": null, "verifiedAt": "2026-09-24T11:54:57.14+00:00", "verificationDetail": { "fixture": true }, "verified": true, "decidedAt": "2026-09-24T11:55:54.276+00:00", "settledAt": "2026-09-24T11:55:54.276+00:00", "closedAt": null, "closeReason": null, "agentReasoning": "Milestone 'Landing page redesign — milestone 2' for 0.9 USDC is verified via timesheet:kimai, contractor Diego Ramirez has riskLevel 'clear' (not high), and the amount 0.9 is below his paymentLimit of 2.5. No duplicate invoice or PO mismatch indicated, and verification source is confirmed, so immediate release is justified rather than deferring to Net-30.", "txHash": "0xa710040ac59af4501a4c91f70287f1fecec1aad037e5d0fc2141fe270f81d969", "contractor": { "id": "5541f1a4-4e48-4fa5-880c-9ffc97d8953b", "name": "Diego Ramirez — Design Contractor", "riskLevel": "clear" }, "createdAt": "2026-09-24T11:54:57.933771+00:00" } ], "page": { "nextCursor": "eyJrIjoiMjAyNi0wOS0yNFQxMTo1NDo1Ny45MzM3NzErMDA6MDAiLCJpZCI6ImQwMWIyYjQ5LTJjYTMtNDVmZS04OThjLWYyYzAxMjhmNjE4OCJ9", "hasMore": true, "count": 1 } } ``` **Fields** - `data` (array of object, required) - `id` (string, required) - `title` (string, required) - `amount` (number, required) - `status` (string, required) One of `pending`, `verified`, `paid`, `held`, `closed`. - `verificationSource` (string, nullable, required) - `verificationMethod` (string, required) One of `unverified`, `github`, `manual`, `seed`. - `verificationStatus` (string, required) One of `unverified`, `verified`, `not_merged`, `unavailable`, `failed`. - `verificationCheckedAt` (string, nullable, required) - `verifiedAt` (string, nullable, required) - `verificationDetail` (object, required) - `verified` (boolean, required) - `decidedAt` (string, nullable, required) - `settledAt` (string, nullable, required) - `closedAt` (string, nullable, required): When a person closed the milestone without paying it (status closed), else null. - `closeReason` (string, nullable, required): The reason the person gave for closing it without paying, else null. - `agentReasoning` (string, nullable, required) - `txHash` (string, nullable, required): An on-chain hash when the payment settled on Arc, else null. - `contractor` (object, nullable, required) - `id` (string, required) - `name` (string, required) - `riskLevel` (string, required) - `createdAt` (string, required) - `page` (object, required): Where this page sits in the collection. - `nextCursor` (string, nullable, required): Pass back as `?cursor=` to continue. Null when the end is reached. - `hasMore` (boolean, required): Whether another page follows this one. - `count` (integer, required): How many items this response carries. ## Errors | Status | Code | When | | --- | --- | --- | | 400 | `invalid_request` | An invalid `limit` or `cursor`, a filter value outside its allowed values, or a request body that does not validate. The message names the parameter or the field, and lists the accepted values. | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | --- # Add a milestone `POST /api/v1/milestones` Adds work a contractor is to be paid for, checked by the rules of the console's Add milestone form, and recorded in the ledger as `create_milestone` with `via: "api"` and the key's id. It is added as the key's issuer's: if the agent holds it on its own judgment, someone other than the issuer must choose Pay now, unless the issuer is the workspace's only approver. It starts `pending`, and the API cannot verify it. A GitHub pull request in `verificationSource` is checked by the agent, which verifies the milestone once the pull request is merged; any other link is evidence for the person who verifies it on Contractors. Once it is verified, the agent decides the payment with every guardrail, and pays only to the contractor's confirmed address. A `contractorId` the workspace does not hold, or a client's, answers `400`. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) with read and write access as `Authorization: Bearer `. A read-only key gets `403`. ## Parameters | Name | In | Type | Required | Default | Allowed values | Description | | --- | --- | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | Optional | — | — | Makes a retry safe: up to 255 printable ASCII characters, unique to the record being added, such as its id in your own system. A repeat with the same key and the same body within 24 hours gets the first answer back, with `Idempotent-Replayed: true`, and adds nothing. The same key with a different body answers `409`. | ## Request body Example, `application/json`: ```json { "contractorId": "26d6ffed-356d-474a-8d42-89bc942f6b4e", "title": "TypeScript SDK for the API", "amount": "0.10", "verificationSource": "https://github.com/duongnq2798/vestiarion/pull/176" } ``` **Fields** - `contractorId` (string, required): The `id` of the contractor or vendor to pay, from `GET /api/v1/counterparties` or from the answer that added it. A client is not paid for milestones. - `title` (string, required): What was delivered, 3 to 160 characters. - `amount` (string | number, required): What the work is paid, in USDC, with at most 6 decimal places. A decimal string such as `"250.00"` keeps it exact; a number is read the same way. - `verificationSource` (string, optional): A link to the delivered work: https, up to 500 characters. A GitHub pull request (`https://github.com///pull/`) is checked by the agent, which verifies the milestone once it is merged. Any other link is evidence for the person who verifies the milestone on Contractors. ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/milestones" \ -X POST \ -H "Authorization: Bearer $VESTIARION_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: ci-bounty-pr-176" \ -d '{"contractorId":"26d6ffed-356d-474a-8d42-89bc942f6b4e","title":"TypeScript SDK for the API","amount":"0.10","verificationSource":"https://github.com/duongnq2798/vestiarion/pull/176"}' ``` ## Response Example, `201` `application/json`: ```json { "data": { "id": "354132aa-e7f6-46f8-ae3c-aa6809f9b9b6", "title": "TypeScript SDK for the API", "amount": 0.1, "status": "pending", "verificationSource": "https://github.com/duongnq2798/vestiarion/pull/176", "verificationMethod": "unverified", "verificationStatus": "unverified", "verificationCheckedAt": null, "verifiedAt": null, "verificationDetail": {}, "verified": false, "decidedAt": null, "settledAt": null, "closedAt": null, "closeReason": null, "agentReasoning": null, "txHash": null, "contractor": { "id": "26d6ffed-356d-474a-8d42-89bc942f6b4e", "name": "API Test Contractor", "riskLevel": "clear" }, "createdAt": "2026-10-03T15:43:09.204631+00:00" } } ``` **Fields** - `data` (object, required): A contractor milestone, how it was verified, and whether it was paid. - `id` (string, required) - `title` (string, required) - `amount` (number, required) - `status` (string, required) One of `pending`, `verified`, `paid`, `held`, `closed`. - `verificationSource` (string, nullable, required) - `verificationMethod` (string, required) One of `unverified`, `github`, `manual`, `seed`. - `verificationStatus` (string, required) One of `unverified`, `verified`, `not_merged`, `unavailable`, `failed`. - `verificationCheckedAt` (string, nullable, required) - `verifiedAt` (string, nullable, required) - `verificationDetail` (object, required) - `verified` (boolean, required) - `decidedAt` (string, nullable, required) - `settledAt` (string, nullable, required) - `closedAt` (string, nullable, required): When a person closed the milestone without paying it (status closed), else null. - `closeReason` (string, nullable, required): The reason the person gave for closing it without paying, else null. - `agentReasoning` (string, nullable, required) - `txHash` (string, nullable, required): An on-chain hash when the payment settled on Arc, else null. - `contractor` (object, nullable, required) - `id` (string, required) - `name` (string, required) - `riskLevel` (string, required) - `createdAt` (string, required) ## Errors | Status | Code | When | | --- | --- | --- | | 400 | `invalid_request` | An invalid `limit` or `cursor`, a filter value outside its allowed values, or a request body that does not validate. The message names the parameter or the field, and lists the accepted values. | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 409 | `conflict` | The `Idempotency-Key` was already used for a different request, or the first request with it is still being handled. | | 429 | `rate_limited` | Too many requests; wait as long as `Retry-After` says before trying again. | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | --- # Get the treasury `GET /api/v1/treasury` The workspace's accounts and reserve position, its obligations from the latest cycle snapshot, the latest liquidity forecast, and up to 20 recent treasury moves. Obligations stay `null` before any snapshot exists; the API does not invent zeroes. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) as `Authorization: Bearer `. ## Parameters No parameters. ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/treasury" \ -H "Authorization: Bearer $VESTIARION_API_KEY" ``` ## Response Example, `200` `application/json`: ```json { "data": { "accounts": [ { "id": "55ce33e5-4b1c-47e6-838b-2446b80300c7", "name": "Base Client Wallet", "kind": "chain", "chain": "BASE-SEPOLIA", "token": "USDC", "address": "0x4d10b4076a4975650e80d087fc9c99610ca76266", "balance": 0, "apy": 0 }, { "id": "6f32681d-2f32-4db2-9841-c44f9be25965", "name": "Arc Operating Wallet", "kind": "operating", "chain": "ARC-TESTNET", "token": "USDC", "address": "0x2fafdda3f973e8f993911f1c2196d5e72d51d71d", "balance": 22.765, "apy": 0 }, { "id": "02a3c356-9571-4d5e-992a-5672dedba590", "name": "USYC Reserve", "kind": "reserve", "chain": "ARC-TESTNET", "token": "USYC", "address": "0x8c99d5b1ee35e7d02887f5064a1b3f1a78a0ab66", "balance": 0, "apy": 0.045 } ], "reservePosition": 0, "obligations": { "asOf": "2026-09-24T18:33:03.344+00:00", "dueWithin7Days": 11.2, "dueWithin14Days": 11.2 }, "latestForecast": { "id": "73400ece-0f38-4695-9bad-3816650b71fb", "asOf": "2026-09-24T18:33:03.226+00:00", "horizonDays": 14, "projectedInflow": 9, "projectedOutflow": 11.2, "liquidBalance": 22.765, "recommendation": "Liquidity healthy." }, "recentActions": [] } } ``` **Fields** - `data` (object, required): The workspace's accounts, reserve, obligations, latest forecast and recent treasury moves. - `accounts` (array of object, required) - `id` (string, required) - `name` (string, required) - `kind` (string, required) One of `operating`, `reserve`, `chain`. - `chain` (string, required) - `token` (string, required) - `address` (string, nullable, required) - `balance` (number, required) - `apy` (number, required) - `reservePosition` (number, required): The sum of the reserve accounts' balances. - `obligations` (object, required): From the latest cycle snapshot; null before any snapshot exists, never an invented zero. - `asOf` (string, nullable, required) - `dueWithin7Days` (number, nullable, required) - `dueWithin14Days` (number, nullable, required) - `latestForecast` (object, nullable, required) - `id` (string, required) - `asOf` (string, required) - `horizonDays` (number, required) - `projectedInflow` (number, required) - `projectedOutflow` (number, required) - `liquidBalance` (number, required) - `recommendation` (string, nullable, required) - `recentActions` (array of object, required): Up to 20 treasury moves, newest first. - `id` (string, required) - `action` (string, required) One of `sweep_to_usyc`, `redeem_from_usyc`, `rebalance`. - `amount` (number, required) - `fromAccountId` (string, nullable, required) - `toAccountId` (string, nullable, required) - `reasoning` (string, nullable, required) - `createdAt` (string, required) ## Errors | Status | Code | When | | --- | --- | --- | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | --- # Get insights `GET /api/v1/insights` The telemetry behind the Insights page: recent transfers, cycle runs, cycle snapshots, treasury moves and screenings. `referenceDisagreementCount: null` means the cycle predates the comparison, and `status: "partial"` is reported as it is, not rewritten as completed or failed. Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) as `Authorization: Bearer `. ## Parameters No parameters. ## Code samples ```bash curl "https://www.vestiarion.xyz/api/v1/insights" \ -H "Authorization: Bearer $VESTIARION_API_KEY" ``` ## Response Example, `200` `application/json`: ```json { "data": { "transfers": [ { "id": "359e82e1-958d-4327-9174-e0b885eebe8f", "targetType": "invoice", "targetId": "1d7e1714-3f84-493e-a3ea-c984a2881100", "txRef": "0x36ea71ac97cad3743f227860785207e6f25148251735569be0f6b16c73130343", "feeUsd": 0.003248, "feeSource": "chain_reported", "settledInMs": 5000, "chain": "ARC-TESTNET", "providerMode": "live", "executedAt": "2026-09-24T11:55:24.131+00:00", "status": "confirmed" } ], "runs": [ { "id": "8cbcf6ca-2d58-43c9-9237-3000851e7046", "startedAt": "2026-09-24T11:53:06.672+00:00", "finishedAt": "2026-09-24T11:53:16.101+00:00", "durationMs": 9429, "decisionCount": 1, "paidCount": 0, "heldCount": 0, "flaggedCount": 0, "awaitingInfoCount": 0, "releasedCount": 0, "modelDecisionCount": 1, "heuristicDecisionCount": 0, "guardrailOverrideCount": 0, "referenceDisagreementCount": null, "status": "completed", "failedStage": null, "errorMessage": null, "chainMode": "live", "screeningMode": "simulate" }, { "id": "bd76f653-4352-42b3-aec5-5ddbbe01c882", "startedAt": "2026-09-24T15:22:50.871+00:00", "finishedAt": "2026-09-24T15:23:00.617+00:00", "durationMs": 9746, "decisionCount": 0, "paidCount": 0, "heldCount": 0, "flaggedCount": 0, "awaitingInfoCount": 0, "releasedCount": 0, "modelDecisionCount": 0, "heuristicDecisionCount": 0, "guardrailOverrideCount": 0, "referenceDisagreementCount": 0, "status": "partial", "failedStage": "ap", "errorMessage": "ap: AGENT_LLM_PROVIDER=openai but OPENAI_API_KEY is not set; treasury: AGENT_LLM_PROVIDER=openai but OPENAI_API_KEY is not set", "chainMode": "live", "screeningMode": "simulate" } ], "snapshots": [ { "id": "352d94e4-a945-420f-a933-34b06b46412b", "cycleRunId": "8cbcf6ca-2d58-43c9-9237-3000851e7046", "capturedAt": "2026-09-24T11:53:16.101+00:00", "totalLiquid": 27.58, "openPayables": 10.695, "openReceivables": 9, "obligationsDue7d": 12.195, "obligationsDue14d": 12.195, "reservePosition": 0, "chainMode": "live" } ], "treasuryMoves": [], "screenings": [ { "id": "54d6f2a8-d939-45ef-a41e-61f88fd362ae", "counterpartyId": "dc5e5751-3287-46c9-8bd1-83a42ab02699", "counterpartyName": "Anthropic API Services", "riskLevel": "clear", "previousRiskLevel": null, "tierChanged": false, "mode": "simulate", "source": "simulated-sanctions-list", "status": "complete", "createdAt": "2026-09-24T11:55:11.147351+00:00" } ] } } ``` **Fields** - `data` (object, required): The telemetry behind the Insights charts: the most recent transfers, cycle runs, snapshots, treasury moves and screenings, each oldest first. - `transfers` (array of object, required) - `id` (string, required) - `targetType` (string, required) One of `invoice`, `milestone`. - `targetId` (string, required) - `txRef` (string, required) - `feeUsd` (number, required) - `feeSource` (string, required) One of `chain_reported`, `provider_estimate`, `simulated_profile`. - `settledInMs` (number, nullable, required) - `chain` (string, required) - `providerMode` (string, required) One of `live`, `simulate`. - `executedAt` (string, required) - `status` (string, required) - `runs` (array of object, required) - `id` (string, required) - `startedAt` (string, required) - `finishedAt` (string, required) - `durationMs` (number, required) - `decisionCount` (number, required) - `paidCount` (number, required) - `heldCount` (number, required) - `flaggedCount` (number, required) - `awaitingInfoCount` (number, required) - `releasedCount` (number, required) - `modelDecisionCount` (number, required) - `heuristicDecisionCount` (number, required) - `guardrailOverrideCount` (number, required) - `referenceDisagreementCount` (number, nullable, required): Model verdicts that chose a different action from the rule-based policy. Null for cycles run before the comparison existed, never zero, which would claim perfect agreement over decisions never compared. - `status` (string, required): A cycle that failed partway is not a quiet cycle, and must not read as one. Its counts are real but partial: they cover the stages that ran before it stopped, and nothing after. One of `running`, `completed`, `partial`, `failed`. - `failedStage` (string, nullable, required) - `errorMessage` (string, nullable, required) - `chainMode` (string, required) One of `live`, `simulate`. - `screeningMode` (string, required) One of `live`, `simulate`. - `snapshots` (array of object, required) - `id` (string, required) - `cycleRunId` (string, required) - `capturedAt` (string, required) - `totalLiquid` (number, required) - `openPayables` (number, required) - `openReceivables` (number, required) - `obligationsDue7d` (number, required) - `obligationsDue14d` (number, required) - `reservePosition` (number, required) - `chainMode` (string, required) One of `live`, `simulate`. - `treasuryMoves` (array of object, required) - `id` (string, required) - `action` (string, required) One of `sweep_to_usyc`, `redeem_from_usyc`, `rebalance`. - `amount` (number, required) - `createdAt` (string, required) - `screenings` (array of object, required) - `id` (string, required) - `counterpartyId` (string, required) - `counterpartyName` (string, required) - `riskLevel` (string, required) - `previousRiskLevel` (string, nullable, required) - `tierChanged` (boolean, required) - `mode` (string, required) One of `live`, `simulate`. - `source` (string, required) - `status` (string, required) One of `complete`, `failed`. - `createdAt` (string, required) ## Errors | Status | Code | When | | --- | --- | --- | | 401 | `unauthorized` | No key, or a malformed, unknown or revoked one: "A valid API key is required." | | 403 | `forbidden` | The key's scopes do not cover this route: "This key cannot do that." Or, on a write, the person who created the key can no longer add records: "This key's issuer can no longer add records in this workspace." | | 500 | `internal` | An unexpected server error. Implementation details are not exposed. | --- # Webhooks overview > Signed webhooks that push each ledger entry to your endpoint. A workspace can register HTTPS endpoints that receive its ledger, pushed after each entry is appended, instead of polling [the ledger API](https://www.vestiarion.xyz/docs/api/list-ledger-entries). Every request is signed, so a receiver can prove it came from Vestiarion without trusting the network. ## Adding an endpoint An owner or an admin adds an endpoint on the workspace's Settings page, `/o//settings`, under **Webhooks**. Other members see the list of endpoints but not the controls, and see only each endpoint's host: [Who sees what](https://www.vestiarion.xyz/docs/webhooks/security#who-sees-what). The URL is checked on the spot against the [URL rules](https://www.vestiarion.xyz/docs/webhooks/security#url-rules). When it passes, Vestiarion shows the endpoint's signing secret: `whsec_` followed by 32 random bytes, base64url-encoded. It is **shown once**. It is stored encrypted, bound to the workspace and the endpoint, and cannot be shown again. If you lose it, remove the endpoint and add it back, which issues a fresh secret. A workspace holds **at most 5 active endpoints**. The same panel can send a **test event** to an endpoint, and remove an endpoint. There is no re-enable: a [disabled endpoint](https://www.vestiarion.xyz/docs/webhooks/retries#disabling-an-endpoint) is removed and added again, which also issues it a new secret. ## Events | Event | When | | --- | --- | | `ledger.appended` | A new entry was appended to the workspace's ledger. The body carries the entry. | | `webhook.test` | Someone sent a test event from Settings. The body carries no entry. | [Payload and headers](https://www.vestiarion.xyz/docs/webhooks/payload) shows both. ## Timing Every delivery is queued in the same transaction that appends its ledger entry, and goes out from one of three places: - **Right after the request that appended the entry.** Approving a payment, pausing the agent, changing a member or a key: once the response has been sent, a short dispatch (at most 20 seconds) sends what is due. Entries appended within a few seconds of each other can share one dispatch. The person's request never waits on your endpoint. - **Right after each scheduled agent tick's cycles**, for the entries they appended. The dispatch gets what is left of the tick's own time budget: at most 30 seconds, and none at all when nothing is left; the queue then waits for the next dispatch of any kind. - **On a schedule, every 10 minutes**, for retries and anything still queued. The schedule runs on GitHub Actions, which can start a scheduled run late or skip it under load, so a retry can wait longer than its backoff step; the next append or tick also picks it up. A dispatch run is bounded by 60 seconds and 500 deliveries, claimed in batches of 25. No batch is claimed, and no request is started, with less than about 12 seconds left (one request's 10-second timeout plus a margin), so nothing is left half-sent when a run ends; a queue larger than one run is finished by the next. **A test event is sent differently.** Sending it from Settings delivers it at once, inside that person's own request: a single attempt, at most about 10 seconds, with no retry whatever the result. ## In this section - [Payload and headers](https://www.vestiarion.xyz/docs/webhooks/payload): the body and headers of every delivery. - [Verifying signatures](https://www.vestiarion.xyz/docs/webhooks/verify): check the delivery's HMAC, and the ledger entry's own Ed25519 signature. - [Retries and disabling](https://www.vestiarion.xyz/docs/webhooks/retries): the retry schedule, and when an endpoint is disabled. - [Security](https://www.vestiarion.xyz/docs/webhooks/security): the URL rules, how long delivery records are kept, and who sees what. - [Delivery guarantees](https://www.vestiarion.xyz/docs/webhooks/guarantees): at least once, not in order, and how a receiver handles both. --- # Payload and headers > The body and headers of every webhook delivery. Every delivery is a `POST` with `Content-Type: application/json`, the headers below, and a JSON body. ## Headers | Header | Value | | --- | --- | | `User-Agent` | `Vestiarion-Webhooks/1` | | `Vestiarion-Event-Id` | The delivery's id (a UUID): the same on every retry of the same delivery. | | `Vestiarion-Event-Type` | `ledger.appended` or `webhook.test`. | | `Vestiarion-Signature` | `t=,v1=.")>`. See [Verifying the signature](https://www.vestiarion.xyz/docs/webhooks/verify#verifying-the-signature). | ## Body The body is one JSON object: ```json { "id": "3fa1e2b0-...", "type": "ledger.appended", "createdAt": "2026-09-29T11:55:00.370366+00:00", "workspace": { "slug": "acme" }, "entry": { "seq": 82, "ts": "2026-09-29T11:55:00.370366+00:00", "actor": "agent", "domain": "treasury", "action": "sweep_to_usyc", "summary": "Treasury: sweep_to_usyc 60.71 USDC", "detail": { "decision": { "action": "sweep_to_usyc", "amount": 60.71 }, "executed": true }, "bodyHash": "8780d07cb3d0...", "prevHash": "0000...0000", "hash": "b0ac72908868...", "signature": "c14f06b7...", "signingKeyId": "97a8a48af020f908" } } ``` | Field | Meaning | | --- | --- | | `id` | The delivery's id, the same as the `Vestiarion-Event-Id` header. De-duplicate on it. | | `type` | `ledger.appended` or `webhook.test`, the same as the `Vestiarion-Event-Type` header. | | `createdAt` | For `ledger.appended`, the entry's time; for `webhook.test`, the time the test was queued. | | `workspace.slug` | The workspace the event belongs to. | | `entry` | The ledger entry. `ledger.appended` only. | `entry` carries the same fields as [`GET /api/v1/ledger`](https://www.vestiarion.xyz/docs/api/list-ledger-entries) reports for that row, without the row `id`, so a receiver already reading that API recognizes the shape. `signingKeyId` is `null` on entries written before key ids were recorded. A `webhook.test` delivery has no `entry` at all: only `id`, `type`, `createdAt` (the queued time) and `workspace`. ## Your response Answer with any `2xx` status, within 10 seconds. Vestiarion reads at most 1 KB of your response body and does not otherwise inspect the body or the headers. Any other status, a timeout or a redirect is a failed attempt: see [Retries and disabling](https://www.vestiarion.xyz/docs/webhooks/retries). --- # Verifying signatures > Check a delivery's signature, and a ledger entry's Ed25519 signature. Check every delivery's signature before you act on it. A `ledger.appended` entry also carries its own Ed25519 signature, which you can check as well. ## Verifying the signature Recompute the HMAC over the exact raw request body you received (not a re-serialized copy of it: whitespace and key order must match byte for byte), reject anything outside a 5-minute window, and compare in constant time: ```js const crypto = require("node:crypto"); function verifyVestiarionSignature(secret, rawBody, header, toleranceS = 300) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceS) { return false; // missing/malformed timestamp, or too old } const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`, "utf8") .digest(); const given = Buffer.from(parts.v1 ?? "", "hex"); return given.length === expected.length && crypto.timingSafeEqual(given, expected); } // verifyVestiarionSignature(secret, rawBody, request.headers["vestiarion-signature"]) ``` `secret` is the full string shown at creation, `whsec_` prefix included: it is the HMAC key as-is, not decoded first. This was run against the repository's own `signWebhook` (`src/lib/webhooks/sign.ts`): a genuine signature verifies, a tampered body is rejected, and a timestamp older than the tolerance is rejected. Read the raw body before any JSON parser touches it. In Express, for example, mount `express.raw({ type: "application/json" })` on the webhook route and pass `request.body.toString("utf8")` as `rawBody`. ## Verifying a ledger entry's Ed25519 signature The HMAC above proves the request came from Vestiarion. The `entry` inside it carries its own, independent signature, the same one the audit chain uses, so a receiver can also check that the entry itself is authentic, apart from the delivery. What is signed is **not** the entry, and **not** the `bodyHash` string: it is the 32 raw bytes you get by hex-decoding `bodyHash`. `bodyHash` is `sha256(canonicalJson({actor, domain, action, summary, detail}))`, where `canonicalJson` sorts object keys at every level (`src/lib/ledger.ts`). If you want to confirm the entry's content matches its `bodyHash` too, rather than only checking the signature, you need that same canonicalization. Checking the signature alone does not require it: `bodyHash` is already given. The public key is not returned by either verify endpoint's JSON ([`GET /api/v1/ledger/verify`](https://www.vestiarion.xyz/docs/api/verify-ledger), or the legacy `GET /api/ledger/verify?org=` the Audit page uses): both report only whether the chain checks out. The PEM itself, and the `signingKeyId` it matches, are shown to any signed-in member on the workspace's Audit page (`/o//audit`, under "Ledger signing public key"). Match `entry.signingKeyId` against the id shown there before trusting a signature: a workspace that has rotated its key may have entries signed by more than one. ```js const crypto = require("node:crypto"); function verifyLedgerEntrySignature(entry, publicKeyPem) { const key = crypto.createPublicKey(publicKeyPem); return crypto.verify( null, // Ed25519: no separate digest algorithm Buffer.from(entry.bodyHash, "hex"), key, Buffer.from(entry.signature, "hex") ); } // verifyLedgerEntrySignature(payload.entry, pemFromTheAuditPage) ``` This was run against the repository's own signing path: an entry built and signed with `bodyHashOf`, `crypto.sign(null, ...)` and `ledgerKeyId` exactly as `src/lib/ledger.ts` does it, then checked with the snippet above. A genuine entry verifies, a tampered `detail` fails (because the recomputed `bodyHash` no longer matches, so the signature, over the original hash, no longer matches the entry either), and the wrong public key is rejected. --- # Retries and disabling > How a failed delivery is retried, and when an endpoint is disabled. A failed attempt is retried on a fixed schedule. An endpoint that keeps failing is disabled. ## Retries A non-2xx response, a timeout, or a redirect schedules a retry. Redirects are never followed: a 3xx counts as a failure, the same as any other non-2xx. **`webhook.test` never retries.** A test event's failure is final on its first and only attempt. For `ledger.appended` deliveries, up to 7 attempts are made in total, with these waits between them: | After attempt | Wait before the next | | --- | --- | | 1 | about 1 minute | | 2 | about 5 minutes | | 3 | about 30 minutes | | 4 | about 2 hours | | 5 | about 6 hours | | 6 | about 12 hours | After the 7th failed attempt the delivery is `failed` for good. Each request has a 10-second timeout, and at most 1 KB of the response body is read; neither the body nor the headers of the response are otherwise inspected. A retry goes out on the next dispatch after its wait is over, so it can leave later than the table says. Every retry of a delivery carries the same `Vestiarion-Event-Id`. ## Disabling an endpoint Every failed attempt on the receiver's side, `webhook.test` included, counts against the endpoint's consecutive-failure count. After **20 consecutive failed attempts**, the endpoint is disabled and its still-pending deliveries are failed outright. There is no re-enabling in place: remove the endpoint and add it back, which also issues a fresh secret. Settings shows each endpoint's status, its last success, its last failure and its failure count, so you can see an endpoint heading for that limit. ## Failures on Vestiarion's side A failure on Vestiarion's side, such as the endpoint's secret not being readable, or its stored URL no longer parsing or passing the [URL rules](https://www.vestiarion.xyz/docs/webhooks/security#url-rules), is retried on the same schedule, and still ends the delivery `failed` after the 7th attempt. But it **never counts against your endpoint and never disables it**. A destination that is, or resolves to, a non-public address is not such a failure: it counts, like any other failed attempt. ## Catching up after a failure A delivery that failed for good is not sent again. To fill the gap, resume the [ledger](https://www.vestiarion.xyz/docs/api/list-ledger-entries) from your stored cursor: see [Delivery guarantees](https://www.vestiarion.xyz/docs/webhooks/guarantees#reconciling-with-the-ledger). --- # Security > Which endpoint URLs are accepted, how long deliveries are kept, and who sees them. Which endpoint URLs Vestiarion accepts, how long it keeps delivery records, and which members see what. ## URL rules To keep webhooks from being turned against private networks (server-side request forgery, SSRF), the URL is checked when an endpoint is added, and again at send time: - `https:` only, port 443 or unspecified, no username or password in the URL, at most 500 characters. - Every address the destination has must be public. Refused: this-network, private (RFC 1918), CGNAT, loopback, link-local (including cloud metadata addresses), IETF protocol assignments, benchmarking, documentation, multicast and reserved ranges, and their IPv6 equivalents (unique local, link-local, documentation, Teredo). An address that carries an IPv4 one (IPv4-mapped, NAT64 or 6to4) is judged by that IPv4 address. Otherwise only global IPv6 unicast is accepted. - A host name is resolved once, at connect time, inside the request's 10-second timeout. Every answer must be public, and the connection is pinned to only the addresses just checked, so a name cannot answer with a public address for the check and a private one for the connection (DNS rebinding). An IP-literal host is never resolved; it is checked directly, when the endpoint is added and again before connecting. - Redirects are never followed. ## Retention Delivery records are deleted by a daily cleanup job after 30 days: a `delivered` record 30 days after its delivery, a `failed` one 30 days after it was created. Records still pending or being sent are never deleted. ## Who sees what Owners and admins (the `webhooks.manage` permission) can add an endpoint, send it a test event and remove it, and they are the only members shown each endpoint's full URL. Every other member of the workspace sees the endpoint list (status, last success, last failure and failure count) with only each endpoint's **host**, never its full URL, since a URL may name a customer's own system. ## The signing secret Each endpoint has its own secret, shown once when the endpoint is added and stored encrypted, bound to the workspace and the endpoint. Keep it on your server, as you would an [API key](https://www.vestiarion.xyz/docs/get-started/authentication#keeping-a-key-safe). If it leaks, remove the endpoint and add it back: the new endpoint gets a new secret. --- # Delivery guarantees > At-least-once and out-of-order delivery, and how a receiver handles both. Webhooks are delivered at least once, and not in order. This page says why, and how a receiver handles both. ## At least once The same event can arrive more than once: for example, when your endpoint answered but the answer was lost, or a dispatch run stopped mid-request. Every retry of an event carries the same `Vestiarion-Event-Id` (and the same `id` in the body), so de-duplicate on it. ## Not in order Deliveries can arrive out of order: a retry of an older entry can land after a newer one, and several dispatch runs may be at work. Each `ledger.appended` event carries the entry's `seq`. Order by `entry.seq`, which ascends within a workspace, with gaps: the hash chain, not `seq`, proves continuity. ## A receiver, step by step 1. Read the raw body, and [verify the signature](https://www.vestiarion.xyz/docs/webhooks/verify#verifying-the-signature). Answer `401` or `400` to anything that fails, and stop. 2. If you have already stored this `Vestiarion-Event-Id`, answer `200` and stop. 3. Store the event, keyed by its id, and for `ledger.appended` by `entry.seq` too, so the ledger lands in order however the deliveries arrived. 4. Answer `2xx` within 10 seconds. Do slow work after you answer, from your own queue, so a slow job does not turn into a timeout and a retry. ## Reconciling with the ledger Webhooks can leave gaps: a delivery [fails for good](https://www.vestiarion.xyz/docs/webhooks/retries) after 7 attempts, an endpoint is disabled, or an entry was appended before the endpoint existed. Queueing a delivery also never blocks the ledger: if it fails, the entry is still appended, with no delivery queued for it. The ledger itself has no gaps a cursor can miss. Now and then, resume [`GET /api/v1/ledger`](https://www.vestiarion.xyz/docs/api/list-ledger-entries) from your stored cursor and store any entry whose `seq` you do not have yet. [Pagination](https://www.vestiarion.xyz/docs/get-started/pagination#the-ledger-cursor-is-a-watermark) explains the cursor. --- # AI integration > Markdown views, llms.txt and the OpenAPI document, for coding agents. These docs are written to be read by coding agents as well as people. Every page has a Markdown version, the whole site is available as one text file, and the API is described by an OpenAPI document. None of them needs a key. ## Markdown views Append `.md` to any docs URL to get the page as Markdown: - [`https://www.vestiarion.xyz/docs/get-started/quickstart.md`](https://www.vestiarion.xyz/docs/get-started/quickstart.md) - [`https://www.vestiarion.xyz/docs/api/list-invoices.md`](https://www.vestiarion.xyz/docs/api/list-invoices.md) - [`https://www.vestiarion.xyz/docs.md`](https://www.vestiarion.xyz/docs.md), for the overview Each page's header also has **Copy page**, which copies the same Markdown, and a **Markdown** link to it. A reference page's Markdown has its request line, parameters, a cURL sample, the example response, the response fields and the errors. Links in the Markdown are absolute, so they still work once the text is pasted elsewhere. ## llms.txt - [`/llms.txt`](https://www.vestiarion.xyz/llms.txt) lists every page, with a one-line summary and a link to its Markdown view, following the llms.txt convention. - [`/llms-full.txt`](https://www.vestiarion.xyz/llms-full.txt) is every page's Markdown in one file, in the order of the sidebar. Give an agent `llms-full.txt` when it needs the whole API at once, and `llms.txt` when it should fetch only the pages it needs. ## The OpenAPI document [`/api/v1/openapi.json`](https://www.vestiarion.xyz/api/v1/openapi.json) is an OpenAPI 3.1 document for every `/api/v1` endpoint: its parameters with their types, defaults and allowed values, its response schema, its errors, and the bearer authentication. It is built from the same schemas the API's tests check every endpoint's responses against. Use it to generate a client, or to give an agent exact types. ## MCP server The pages above help an agent write code against the API. To let an agent read your workspace's records directly, connect it to the [MCP server](https://www.vestiarion.xyz/docs/ai-integration/mcp) at `https://www.vestiarion.xyz/api/mcp` with a workspace API key. Its tools are the API's read operations, one each, and none of them writes. The page has setup for Claude Code, Cursor, Codex and clients that only start local servers. ## Setup for coding agents Each block below tells the agent where the docs are and how to handle the key. Add it to your project, and keep the key itself in the `VESTIARION_API_KEY` environment variable, never in the file. ### Claude Code Add this to `CLAUDE.md` at the root of your project: ```markdown ## Vestiarion API - Read https://www.vestiarion.xyz/llms-full.txt before writing code that calls the Vestiarion API. - Take request and response types from https://www.vestiarion.xyz/api/v1/openapi.json. - Read the API key from the VESTIARION_API_KEY environment variable and send it as `Authorization: Bearer `. Never hard-code it, log it, or put it in a URL or in client-side code. ``` ### Codex Add this to `AGENTS.md` at the root of your project: ```markdown ## Vestiarion API - Read https://www.vestiarion.xyz/llms-full.txt before writing code that calls the Vestiarion API. - Take request and response types from https://www.vestiarion.xyz/api/v1/openapi.json. - Read the API key from the VESTIARION_API_KEY environment variable and send it as `Authorization: Bearer `. Never hard-code it, log it, or put it in a URL or in client-side code. ``` ### Cursor Add a project rule at `.cursor/rules/vestiarion.mdc`: ```markdown --- description: Calling the Vestiarion API alwaysApply: false --- - Read https://www.vestiarion.xyz/llms-full.txt before writing code that calls the Vestiarion API. - Take request and response types from https://www.vestiarion.xyz/api/v1/openapi.json. - Read the API key from the VESTIARION_API_KEY environment variable and send it as `Authorization: Bearer `. Never hard-code it, log it, or put it in a URL or in client-side code. ``` ## Prompts to start from Paste one of these into your agent once it is set up. A webhook receiver: ```text Write a webhook receiver in Node that verifies Vestiarion signatures and de-duplicates on the event id. Follow https://www.vestiarion.xyz/docs/webhooks/verify.md for the signature check, over the raw request body, and https://www.vestiarion.xyz/docs/webhooks/guarantees.md for de-duplication and ordering. Read the endpoint's secret from the VESTIARION_WEBHOOK_SECRET environment variable. ``` An invoice sync: ```text Sync payable invoices into a spreadsheet, paging with the cursor. Use GET /api/v1/invoices?direction=payable from https://www.vestiarion.xyz/docs/api/list-invoices.md, pass page.nextCursor back unchanged until hasMore is false, and write one row per invoice with its id, status, amount, currency, counterparty name, due date, settlement time and txHash. Read the key from VESTIARION_API_KEY. ``` An alert on held payments: ```text Alert when a payment is held, quoting the agent's reasoning. Poll GET /api/v1/invoices?status=held and GET /api/v1/milestones?status=held (see https://www.vestiarion.xyz/llms-full.txt), remember which ids you have already alerted on, and for each new one post its counterparty or contractor, its amount and its agentReasoning. Read the key from VESTIARION_API_KEY. ``` --- # MCP server > Connect an AI agent to your workspace's records through MCP tools: every API read, and with a read-and-write key, adding counterparties, invoices, milestones and payee links. Vestiarion runs a remote [MCP](https://modelcontextprotocol.io) server. Connect an AI agent to it with a workspace API key, and the agent can answer questions from your workspace's own records: which payments are held and why, whether the ledger is intact, what is due to contractors. Each tool is one of the API's operations. A tool call runs that operation with your key and returns its JSON exactly as the API does. With a read-only key, an agent connected here can read and explain, and nothing more. With a [read-and-write key](https://www.vestiarion.xyz/docs/get-started/authentication#scopes), it can also add counterparties, invoices and milestones, which the agent in Vestiarion then decides as it decides any other, and make payee links. No tool verifies work, approves or pays. ## The server ```text https://www.vestiarion.xyz/api/mcp ``` - **Transport:** Streamable HTTP. The server keeps no session, so a client sends every message as a `POST`. A `GET` or a `DELETE` with a valid key answers `405`: there is no event stream to open and no session to end. - **Protocol versions:** the 2026-07-28 revision of MCP, and clients on the earlier revisions, such as 2025-06-18 and 2025-11-25, at the same URL. - **Surface:** tools only. The server has no resources and no prompts. ## Authentication Send a [workspace API key](https://www.vestiarion.xyz/docs/get-started/authentication) in the `Authorization` header, exactly as for `/api/v1`: ```http Authorization: Bearer ``` The key is checked before any MCP message is read: - A missing, malformed, unknown or revoked key answers `401` with the API's own error body and a `WWW-Authenticate: Bearer realm="vestiarion"` header. - A key without the `read` scope answers `403`, with `error="insufficient_scope"` in `WWW-Authenticate`. Every key issued today has that scope. - The header is the only place a key is read. A key in the query string or in an MCP message's `_meta` is ignored: without the header, the request answers `401`. - If the key cannot be looked up at all, the answer is `500`, not `401`: retry later, and keep the key. See [When authentication fails](https://www.vestiarion.xyz/docs/get-started/authentication#when-authentication-fails). Every tool call reads the key's own workspace and no other. Asking `get_counterparty` for another workspace's counterparty answers `not_found`, as the API does. ## Tools One tool per [API operation](https://www.vestiarion.xyz/docs/api). Its name is the operation's id in snake case, its arguments are the operation's parameters, or for a write the fields of its request body and an optional `idempotencyKey`, with their types, allowed values and bounds, and its description, which is what the agent reads, is the operation's summary and description from its reference page. A read's tool is marked read-only and idempotent. The four write tools are marked as adding records: not read-only, and not idempotent. - `create_counterparty`, `create_invoice` and `create_milestone` are not destructive. A repeat adds another record, unless it passes the same `idempotencyKey`. - `create_payee_link` is marked destructive, because a new link revokes the payee's unused one. It takes no `idempotencyKey`: the link is in its answer only, so a repeat makes a new link. | Tool | What it answers | Reference | | --- | --- | --- | | `get_status` | Get workspace status | [`GET /api/v1/status`](https://www.vestiarion.xyz/docs/api/get-status) | | `list_ledger_entries` (arguments: `limit`, `cursor`, `domain`, `actor`) | List ledger entries | [`GET /api/v1/ledger`](https://www.vestiarion.xyz/docs/api/list-ledger-entries) | | `verify_ledger` | Verify the ledger | [`GET /api/v1/ledger/verify`](https://www.vestiarion.xyz/docs/api/verify-ledger) | | `list_invoices` (arguments: `limit`, `cursor`, `direction`, `status`, `counterpartyId`) | List invoices | [`GET /api/v1/invoices`](https://www.vestiarion.xyz/docs/api/list-invoices) | | `create_invoice` (arguments: `direction`, `counterpartyId`, `amount`, `currency`, `dueDate`, `memo`, `poReference`, `goodsReceived`, `earlyPayDiscount`, `idempotencyKey`) | Add an invoice | [`POST /api/v1/invoices`](https://www.vestiarion.xyz/docs/api/create-invoice) | | `list_counterparties` (arguments: `limit`, `cursor`, `role`, `riskLevel`) | List counterparties | [`GET /api/v1/counterparties`](https://www.vestiarion.xyz/docs/api/list-counterparties) | | `get_counterparty` (arguments: `id`) | Get a counterparty | [`GET /api/v1/counterparties/{id}`](https://www.vestiarion.xyz/docs/api/get-counterparty) | | `create_counterparty` (arguments: `name`, `role`, `address`, `chain`, `jurisdiction`, `paymentLimit`, `noticeEmail`, `idempotencyKey`) | Add a counterparty | [`POST /api/v1/counterparties`](https://www.vestiarion.xyz/docs/api/create-counterparty) | | `create_payee_link` (arguments: `counterpartyId`) | Create a payee link | [`POST /api/v1/payee-links`](https://www.vestiarion.xyz/docs/api/create-payee-link) | | `list_milestones` (arguments: `limit`, `cursor`, `status`, `contractorId`) | List milestones | [`GET /api/v1/milestones`](https://www.vestiarion.xyz/docs/api/list-milestones) | | `create_milestone` (arguments: `contractorId`, `title`, `amount`, `verificationSource`, `idempotencyKey`) | Add a milestone | [`POST /api/v1/milestones`](https://www.vestiarion.xyz/docs/api/create-milestone) | | `get_treasury` | Get the treasury | [`GET /api/v1/treasury`](https://www.vestiarion.xyz/docs/api/get-treasury) | | `get_insights` | Get insights | [`GET /api/v1/insights`](https://www.vestiarion.xyz/docs/api/get-insights) | What a call returns: - **A success** is the API's JSON, as text and as structured content. - **An API error** is a tool error (`isError`) whose text is the API's error body, such as a `400 invalid_request` for a cursor from another endpoint, or a `404 not_found` for an unknown id. The agent reads the message and corrects its call. A read-only key calling a write tool gets the API's `403 forbidden` the same way. The error codes are on [Errors](https://www.vestiarion.xyz/docs/get-started/errors). - **A collection** is one page, 50 items unless the agent passes `limit`, up to 200. The agent passes `page.nextCursor` back as `cursor` for the next page, as described in [Pagination](https://www.vestiarion.xyz/docs/get-started/pagination). - **Nothing is cut.** A tool returns the whole response, `get_insights` included, as the API documents it. ## Setup Keep the key in the `VESTIARION_API_KEY` environment variable. None of the blocks below writes the key itself into a file. ### Claude Code ```bash claude mcp add --transport http vestiarion https://www.vestiarion.xyz/api/mcp \ --header "Authorization: Bearer $VESTIARION_API_KEY" ``` Your shell puts the key into the command, and Claude Code saves the header in its own configuration, `~/.claude.json`. To share the server with a project instead, without the key, add it to `.mcp.json` at the project root. Claude Code expands `${VESTIARION_API_KEY}` from the environment when it starts: ```json { "mcpServers": { "vestiarion": { "type": "http", "url": "https://www.vestiarion.xyz/api/mcp", "headers": { "Authorization": "Bearer ${VESTIARION_API_KEY}" } } } } ``` ### Cursor Add the server to `.cursor/mcp.json` in your project, or to `~/.cursor/mcp.json` for every project. Cursor resolves `${env:VESTIARION_API_KEY}` from the environment: ```json { "mcpServers": { "vestiarion": { "url": "https://www.vestiarion.xyz/api/mcp", "headers": { "Authorization": "Bearer ${env:VESTIARION_API_KEY}" } } } } ``` ### Codex Add the server to `~/.codex/config.toml`, or to `.codex/config.toml` in a trusted project. `bearer_token_env_var` names the variable Codex reads the key from and sends as `Authorization: Bearer`: ```toml [mcp_servers.vestiarion] url = "https://www.vestiarion.xyz/api/mcp" bearer_token_env_var = "VESTIARION_API_KEY" ``` ### Clients that only start local servers A client that runs MCP servers as local commands over stdio can reach this one through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), which relays between stdio and the remote server: ```json { "mcpServers": { "vestiarion": { "command": "npx", "args": [ "-y", "mcp-remote", "https://www.vestiarion.xyz/api/mcp", "--header", "Authorization: Bearer ${VESTIARION_API_KEY}" ] } } } ``` `mcp-remote` replaces `${VESTIARION_API_KEY}` from its own environment, so the key never appears in the arguments. If your client does not pass `VESTIARION_API_KEY` on to the servers it starts, set the variable in that server's `env` entry. Some clients do not escape spaces inside `args` when they start `npx`; the `mcp-remote` README names Cursor, the Codex CLI and Claude Desktop on Windows. There, write the header as `Authorization:${VESTIARION_AUTH_HEADER}`, with no spaces, and set `VESTIARION_AUTH_HEADER` to `Bearer ` followed by the key. ## Questions to ask Once the server is connected, ask in plain language. The agent picks the tools: - "Is our ledger intact, and are any payments held? Why was each one held?" uses `verify_ledger`, then `list_invoices` and `list_milestones` with `status: "held"`, whose entries carry the agent's reasoning. - "Which contractor milestones are verified but not paid yet?" uses `list_milestones`. - "Which counterparties are high risk, and what did their latest screening find?" uses `list_counterparties` with `riskLevel: "high"`, then `get_counterparty`. - "How much do we hold in reserve, and what does the latest liquidity forecast say?" uses `get_treasury`. - "Are payments on this workspace live or simulated?" uses `get_status`. - With a read-and-write key, "Add Quill Studio's invoice for 420 USDC against PO-4012, due October 31; the work was delivered" uses `list_counterparties` to find Quill Studio, then `create_invoice`. - With a read-and-write key, "Pay Mona 150 USDC for https://github.com/acme/app/pull/42 once it is merged" uses `list_counterparties` to find Mona, then `create_milestone`. ## What it does not do - **It does not decide.** Approving or rejecting a payment, confirming an address, pausing the agent, and managing members, API keys or webhooks are a person's decisions, made in the console. No tool can make them, and the write tools only add records. - **It has no OAuth.** The server authenticates with a key in a header and has no authorization server, so it cannot be added as a claude.ai web connector, which needs OAuth. Use a client that sends a header, like the ones above. --- # Changelog > Changes to the API and webhooks, newest first. Changes to the API and webhooks that affect an integration, newest first. Changes to the product that do not touch this surface are not listed. ## 2026-10-05: The agent sends again on its own only what one approval may pay - An `ap_reconcile` or `milestone_reconcile` entry can now carry `notResubmittedBecause: "workspace.two_approvals"`, with `execution.resultingStatus: "held"`. The payment was never sent and is above the workspace's figure for two approvals. Two people's approvals of it had not been used to pay it, so the agent held it rather than sending it again. - Every ledger entry is still appended the same way, and its shape does not change. - Nothing changes in `/api/v1`. ## 2026-10-05: Approve and pay draws on the USYC reserve, and a person's cash back stays - **Approve and pay brings back what the operating wallet lacks.** A person's paying approval of a new USDC payment from the operating wallet, on Arc testnet or through CCTP with its fee, may now first bring cash back from the reserve. It does so when the wallet cannot cover the payment and the reserve can cover the rest. It brings back the difference, rounded up to the next micro-USDC, with a fifth more of a CCTP fee, which is read again just before the burn. A CCTP payout whose fee could not be read is not covered. **Pay now** on a held milestone does the same. - A new kind of `cash_brought_back` entry: actor `human`, with `detail` `{ by, reason: "approval", invoiceId | milestoneId, amount, neededUsdc, feeCushionUsdc?, operatingBalance, reserveBalance, earnMode, executed, executionNote?, execution? }`. It comes before the payment's own entry. - A redemption that did not move is recorded too, with `executed: false` and why in `executionNote`, and nothing is paid. One Arc testnet had not yet confirmed may still land; asking again for the same payment finds that same redemption rather than sending a second one. - `approval_paid` and `milestone_approval_paid` then carry `fromReserveUsdc`. - When the wallet and the reserve together fall short, Approve and pay refuses, as before, before anything moves. No ledger entry is written for a refusal. - **A person's Bring cash back stays for 24 hours.** For 24 hours after a `cash_brought_back` entry with `reason: "person"`, the agent's treasury decisions sweep nothing. - Their entries (`hold`, or `redeem_from_usyc` for what falls due) then carry `personCashBack: { amount, at, until }`. - A sweep the model chose anyway is recorded as a `hold`, with what it chose in `boundedByCode`. - Nothing changes in `/api/v1`. ## 2026-10-05: A person's payout to another chain takes the agent's route - An `approval_paid` entry for a new payment to another chain can now carry `payout.route: "gateway"`, with `gatewayBalanceUsdc`, on a person's first attempt. A person's Approve and pay now chooses the route as the agent does: Gateway when the workspace's Gateway balance covers the amount and Gateway's fee, and Gateway costs no more than CCTP; CCTP otherwise. Before, a person's first attempt always went through CCTP. - Approve and pay now refuses, before anything is sent, a CCTP payout the operating wallet cannot cover with its fee, and a Gateway payout the Gateway balance cannot cover. No ledger entry is written for a refusal. - Nothing changes in `/api/v1`. ## 2026-10-05: Two approvals above a figure the workspace sets - **A new guardrail rule**, `workspace.two_approvals`. Above the figure a workspace's owner sets, the agent never pays or schedules a payable, or releases a milestone: code holds it for two people. Its entry carries `guardrailBlocked: true`, `execution.resultingStatus: "held"` and `observed.twoApprovalsAbove`, the figure it was weighed against. A EURC payable is weighed at its `usdcValue`. - **New ledger actions**, each with actor `human`: - `approval_given` (domain `ap`) and `milestone_approval_given` (domain `contractor`): the first of two approvals, which sends nothing. Its `detail` is `{ by, invoiceId | milestoneId, counterpartyId, amount, currency, address, usdcValue?, twoApprovalsAbove, fewApprovers? }`. - `approval_policy_changed` (domain `system`): an owner set, raised, lowered or turned off the figure. Its `detail` is `{ by, from, to }`, each a figure in USDC or null for off. - **`approval_paid` and `milestone_approval_paid`** gain `approvals: [{ by, at }, …]`, the earlier first, and `twoApprovalsAbove` when two people approved the payment. Their `summary` then ends "(the second of two approvals)". - They also carry `fewApprovers: true` when whoever entered the payment, or gave its address, gave one of the two because no one else could. As many of the two as can come from other people must. - An `invoice_reopened` entry's `followUp.changes` can say that two approvals were turned off, or the figure raised to cover the payment. A `milestone_reopened` entry's can say the same. - Nothing changes in `/api/v1`. ## 2026-10-05: A payment Circle did not answer is looked for, never sent again blind - **A payment whose send Circle did not answer is now in flight, not held.** Its decision's `execution.resultingStatus` is `matched`, as for any payment Circle is still settling. - Vestiarion then looks for the send on Circle by its reference before anything is sent again. - If Circle has it, it is recorded as the payment. - If Circle has listed nothing 15 minutes after the send, the payment is sent as a new one. - Reject and Return look for it the same way before they act. - **Payments can also be switched off from the database** (`npm run payments -- off ""`). Every running deployment stops within 10 seconds, with no redeploy. While they are off, the agent appends no entries. - Approve and pay still records a transfer that was already sent while payments are off, since that only reads Circle. - Nothing changes in `/api/v1`. ## 2026-10-05: Checked addresses, a stop switch, and no rejection over an unknown transfer - `POST /api/v1/counterparties` refuses an `address` that mixes capital and small letters when they do not match its EIP-55 checksum: `400 invalid_request`, with the message `address: This address's capital letters do not match its checksum, so a character is likely wrong.` An address written in one case alone is taken as written, as before. - **Payments can be switched off for every workspace** by whoever runs the deployment (`PAYMENTS_DISABLED`). While they are, no agent cycle runs, so the agent appends no entries and webhooks receive none from it. - When a payment's send ends without Circle saying what became of it, the invoice can be neither rejected nor returned until a person approves it, which asks Circle again under the same key. No ledger entry changes shape. - On a deployment with no screening service, a live workspace's counterparties stay `unscreened` instead of being screened against the demo list. Its `compliance_sweep` entries say the screening is incomplete. - Otherwise nothing changes in `/api/v1`. ## 2026-10-05: Two people before the first payment to an address - **A new guardrail rule**, `counterparty.new_payee`. Wherever payments are real, the agent never makes a payment or milestone release that would be the first to an address unless two different parties stand behind it: - the payee sent the address and a member confirmed it; or - one member gave it and another confirmed it. Its entry carries `guardrailBlocked: true` and `execution.resultingStatus: "held"`. - A decision on a first payment records `observed.newPayee: { addressBy, confirmedBy, twoParties }`. `addressBy` is a member id, `"payee"`, or null when no entry says who gave it. - `approval_paid` and `milestone_approval_paid` gain `firstPayment: true` when a person made an address's first payment. Approve and pay, and Pay now, now refuse the member who gave the address, unless they are the workspace's only approver. - An `invoice_reopened` entry's `followUp.changes` can name `the payee's address has received a confirmed payment since` or `a second person now stands behind the payee's address`. - Nothing changes in `/api/v1`. ## 2026-10-05: A EURC payable decided again when the rate changes what held it - An `invoice_reopened` entry gains `reevaluation` when a fresh quote cleared what held a EURC payable. Its shape is `{ trigger, previousDecision: { seq, action, guardrailRule }, before: { rate, usdcValue, swapCostPercent }, after: { rate, usdcValue, swapCostPercent, quotedAt } }`. - `trigger` is one of `rate_available`, `swap_available`, `swap_cost_within_cap` or `value_within_limit`. - `followUp.changes` says the same in words. - The `ap_*` decision that follows such a reopen in the same cycle gains `reevaluation: { reopenedSeq, trigger, previousDecisionSeq, previousAction }`. Its own `fx` is the rate it was decided at. - A `cycle_complete` entry's `events` can include `fx_changed`. The FX watch started that cycle, every 5 minutes at most, not a person, so its `by` is absent. - Nothing changes in `/api/v1`. ## 2026-10-05: The three-way match, checked in code - **A new guardrail rule**, `invoice.match_incomplete`. When the agent chooses to pay or schedule a payable whose goods are not confirmed received, or whose counterparty needs a purchase order and has none on file, code refuses it. Its `ap_pay` or `ap_schedule` entry carries `guardrailBlocked: true`, `guardrailRule: "invoice.match_incomplete"` and `execution.resultingStatus: "awaiting_info"`, and the invoice waits for its details. - An AP decision's `detail.observed` gains `purchaseOrderRequired`: whether the counterparty needed a purchase order when it was decided. Every counterparty needs one unless an owner or admin marked it as paid without them. - **A new ledger action**, delivered to webhooks as `ledger.appended` like every entry: `counterparty_purchase_orders_changed`, actor `human`, domain `compliance`. Its `detail` is `{ by, counterpartyId, from, to }`, where `from` and `to` say whether the counterparty needed a purchase order before and after. - A `cycle_complete` entry's `events` can include `purchase_orders_waived`: a counterparty was marked as paid without purchase orders, so its payables that waited for one were decided again within a minute. - The `invoice_reopened` entry for such a payable names `the counterparty is now paid without purchase orders` in `followUp.changes`. - Nothing changes in `/api/v1`. ## 2026-10-04: A merge starts the cycle - A `cycle_complete` entry's `events` can now include `pull_request_merged`. A pull request was merged in a repository the workspace's GitHub connection covers, and a pending milestone waited on it, so the cycle that verifies and pays it started within a minute of the merge rather than at the next scheduled cycle. - Nothing changes in `/api/v1`. ## 2026-10-04: Bounties from a pull request comment - **A new ledger action**, delivered to webhooks as `ledger.appended` like every entry: `github_bounty_attached`, actor `human`, domain `contractor`. Someone who can write to a repository attached a bounty to a pull request with `/bounty`. Its `detail` is `{ by, via: "github", installationId, login, pullRequest, author, amount, milestoneId, counterpartyId, commentUrl }`. `by` is the member who connected GitHub, under whose name the milestone was added, and `login` is the GitHub user who commented. See [Show payments on GitHub](https://www.vestiarion.xyz/docs/guides/github). - `create_counterparty` and `create_milestone` entries gain `via: "github"`, `installationId` and `login` for the contractor and the milestone a bounty added. - `counterparty_address_changed` entries gain `via: "github"`, `installationId`, `login` and `commentUrl` when a pull request's author set their address with `/payto`. Their `by` is `null`, as for an address sent through a payee link, and the address waits for a member's confirmation the same way. - Nothing changes in `/api/v1`. ## 2026-10-04: Emailed invoices finished by hand - A `create_invoice` entry with `via: "email"` no longer always carries `changed: []`. An owner or admin can now finish an emailed invoice in the invoice form on AP / AR, with **Edit and add** or **Finish and add**. Its `document.changed` then lists the fields they changed from what was read, as for an invoice added from a document. - Such an entry carries no `document` when the email could not be read and the invoice was typed in. - Nothing changes in `/api/v1`. ## 2026-10-04: Cash back for milestones too - A `cash_brought_back` entry with `reason: "payments_due_today"` now also counts the verified milestones waiting to be paid in `neededUsdc` and `payments`, so a cycle brings their cash back from the reserve before it releases them. A milestone whose USDC is locked in escrow is not counted. - A person's **Pay now** on a milestone for a pull request now gets its `pull_request_commented` entry once the payment is confirmed, rather than at the next cycle. ## 2026-10-04: Payments said on the pull request - **A workspace can connect GitHub** from Settings, by installing the Vestiarion app on the repositories it chooses. See [Show payments on GitHub](https://www.vestiarion.xyz/docs/guides/github). - **Three new ledger actions**, delivered to webhooks as `ledger.appended` like every entry: - `github_connected`, with `installationId`, `account`, `accountType` and `repositorySelection`; - `github_disconnected`, with `installationId` and `account`; - `pull_request_commented`, with `milestoneId`, `pullRequest`, `commentUrl`, `amount`, `token` and `txHash`. It is written when the app commented on the pull request a milestone was paid for. The comment names the amount, the network, the paying workspace and the transaction, never the payee. - **Private pull requests verify** in a repository the app is installed on, so `verify_milestone_github` can now follow a private pull request's merge. - Nothing changes in `/api/v1`. ## 2026-10-03: Invoices by email - **New ledger actions**, delivered to webhooks like every entry: - `invoice_inbox_on`, `invoice_inbox_changed` and `invoice_inbox_off`, actor `human`, domain `system`: an owner or admin turned on the workspace's address for invoices by email, replaced it, or turned it off. Their `detail` is `{ by }`; the address is never recorded. - `invoice_email_received`, actor `system`, domain `ap`: an email arrived at the address and was read. Its `detail` is `{ inboxEmailId, from, authentication, read, document }`: the sender with most of its name hidden, SPF/DKIM/DMARC as Resend reported them, `read` one of `ready`, `needs_details` or `unreadable`, and the document's `kind` and `sha256`, or `null`. - `invoice_email_dismissed`, actor `human`, domain `ap`: `{ by, inboxEmailId }`. - `create_invoice` entries gain `via: "email"` and `inboxEmailId` when a member added the payable from an email on AP / AR. Such an entry carries `document` as one added from a document does, with `changed: []`. Nothing that arrives by email is added without a person. - Nothing changes in `/api/v1`. ## 2026-10-03: Add milestones and payee links through the API - **[Add a milestone](https://www.vestiarion.xyz/docs/api/create-milestone)**, `POST /api/v1/milestones`, with a read-and-write key. - It takes `{ contractorId, title, amount, verificationSource? }`, checked by the rules of the console's Add milestone form, and answers `201` with the milestone, shaped as [List milestones](https://www.vestiarion.xyz/docs/api/list-milestones) returns it. - The milestone starts `pending`, and the API cannot verify it. A GitHub pull request in `verificationSource` is verified by the agent once it is merged; any other link waits for a person. - It takes an `Idempotency-Key`, as every write does. - It is recorded as `create_milestone` with `via: "api"` and `apiKeyId`. - **[Create a payee link](https://www.vestiarion.xyz/docs/api/create-payee-link)**, `POST /api/v1/payee-links`, with a read-and-write key. - It takes `{ counterpartyId }`, a vendor or a contractor, and answers `201` with `{ id, counterpartyId, url, expiresAt }`. - `url` is in that answer only, which is sent with `Cache-Control: no-store`. - The operation keeps no outcome for an `Idempotency-Key`, which would store the link. A repeat makes a new link, and the unused one stops working. - It is recorded as `payee_link_created` with `via: "api"` and `apiKeyId`. The address a payee enters through it waits for a person to confirm it, as before. - **A write now checks the key's creator as they are now.** Only owners and admins can add records. If an owner moves the creator to approver or viewer, the key keeps reading, and every write, invoices and counterparties included, answers `403 forbidden`: "This key's issuer can no longer add records in this workspace." - **A milestone records who added it**, from the console, Pay a freelancer or the API. So the self-approval rule now applies to milestones too. When the agent held a milestone on its own judgment, the person who added it cannot choose **Pay now** for it, unless they are the workspace's only approver. Milestones added before keep no creator. - **MCP** gains `create_milestone` and `create_payee_link`. `create_payee_link` is marked destructive, because a new link revokes the unused one. - **`@vestiarion/sdk` 0.2.0** adds `milestones.create` and `payeeLinks.create`. - **Settings:** the box that gives a key write access is now **Can also add records**. ## 2026-10-03: Add an invoice from Slack - `create_invoice` entries gain `via: "slack"` and `linkId` when an owner or admin added the payable from a message in Slack with **Add invoice**. Such an entry carries `document` as one added from a document on AP / AR does, with `changed: []`. - The Slack app asks for one more permission, `files:read`, to read the file someone chooses. A Slack connected before this is connected again from **Settings** with **Reconnect Slack**; its members stay connected. - Nothing changes in `/api/v1`. ## 2026-10-03: A member's API keys end with their membership - A workspace API key now works only while the person who created it is a member of the workspace. When they leave, are removed by an owner or an admin, or delete their account, every active key they created in that workspace, read-only or read-and-write, is revoked in the same step, and from then on answers `401 unauthorized` on `/api/v1` and on MCP, like any revoked key. Keys that other members created, and the person's keys in workspaces they still belong to, keep working. - Each such key gets its own `api_key_revoked` entry, delivered to webhooks like every entry, right after the member's `member_left` or `member_removed`. Its `detail` gains `reason`: `member_left`, `member_removed` (with `member`, the person removed, while `by` is who removed them) or `account_deleted`. A key revoked in Settings now carries `reason: "person"`. - A key can no longer be created for someone who is not a member of the workspace. - A read-and-write key whose issuer has left can no longer add records, so a `create_counterparty` or `create_invoice` entry with `via: "api"` always names its issuer in `by`. - Nothing was revoked when this shipped: no active key had outlived its creator's membership. ## 2026-10-03: The TypeScript SDK on npm - `npm install @vestiarion/sdk` installs it from the npm registry. - Version 0.1.0 there is the same tarball, byte for byte, as `https://www.vestiarion.xyz/sdk/vestiarion-sdk-0.1.0.tgz`, which keeps working. ## 2026-10-03: A member's notifications move to Settings - `telegram_disconnected` entries for a chat its member disconnected from Vestiarion now carry `via: "settings"`: the **Telegram** card moved from **Members** to the **Notifications** section at the top of **Settings**, with the switch for the email about payments that need a decision. Entries made before keep `via: "members_page"`; `via: "telegram"` and `via: "blocked"` are unchanged. - Nothing else changes in the ledger, the webhooks or `/api/v1`. ## 2026-10-03: A TypeScript SDK - **`@vestiarion/sdk` 0.1.0**, installed with `npm install https://www.vestiarion.xyz/sdk/vestiarion-sdk-0.1.0.tgz`. It is a typed client for every `/api/v1` operation, with no dependencies, for Node 20 or later, Deno, Bun and edge runtimes. See [TypeScript SDK](https://www.vestiarion.xyz/docs/get-started/sdk). - Its types are generated from the OpenAPI document, so they follow each change listed here. - `listAll` and `pages` follow `page.nextCursor` to the end of a collection. - Requests are retried after: - a `429`, when its `Retry-After` is 60 seconds or less; - a `5xx`; - a timeout or a network failure. - Every write carries an `Idempotency-Key`, so a retry never adds a record twice. - `verifyWebhook` checks a delivery's `Vestiarion-Signature`. - `verifyLedgerEntry` checks an entry's body hash, Ed25519 signature and chain hash. - Nothing in the API or the webhooks changed. ## 2026-10-03: The agent's decisions in Slack - **New ledger actions**, domain `system`, delivered to webhooks like every entry: - `slack_installed`, actor `human`: an owner or admin connected a Slack workspace from **Settings**, and picked the channel the agent's decisions go to. Its `detail` is `{ by, teamId, channelId }`. - `slack_uninstalled`: the workspace's Slack was disconnected, with every member's Slack link. Actor `human` when an owner or admin chose **Remove Slack** (`via: "settings"`); actor `system` when Slack said the app was removed from its workspace (`via: "slack"`), with `by` `null`. Its `detail` is `{ by, teamId, via }`. - `slack_member_connected`, actor `human`: a member connected their own Slack account. Its `detail` is `{ by, linkId }`, plus `via: "install"` for the account of the person who connected the workspace. - `slack_member_disconnected`, actor `human`: a member disconnected their own Slack account, with `/vestiarion disconnect` (`via: "slack"`) or from **Settings** (`via: "settings"`). Its `detail` is `{ by, userId, linkId, via }`. A member removed from the workspace loses the link with the membership, and that is recorded as the removal. - `slack_decisions_limit_changed`, actor `human`: an owner set the most a payment approved from Slack may be, or turned deciding from Slack off. Its `detail` is `{ by, from, to }`, in USDC, `null` for off. - `approval_paid`, `approval_rejected` and `approval_returned` entries gain `via: "slack"` and `linkId` when a member decided the payable from its message in Slack, and `agent_paused` does when they paused the agent with `/vestiarion pause`. Everything else in them is what the console writes. Entries for actions taken in the console carry no `via`, as before. - A cycle's journal has a new stage, `slack`, after `telegram`, the last. It needs no other stage to have succeeded. - Nothing changes in `/api/v1`. ## 2026-10-03: A payable held for an unconfirmed address goes back to the agent once it is confirmed - A payable the agent held because its counterparty's address changed and no one had confirmed it (`guardrailRule: "counterparty.address_unconfirmed"`, `observed.addressUnconfirmed: true`) now goes back to the agent when a person confirms the address: an `invoice_reopened` entry whose `followUp.changes` reads "the counterparty's new address has since been confirmed", then a new decision in the same cycle. Until now it stayed held until a person approved it. Approving it still pays it, and confirms the address. - The API's two write examples are now responses captured in testnet-2. ## 2026-10-03: Add counterparties and invoices through the API - **New operations:** [`POST /api/v1/counterparties`](https://www.vestiarion.xyz/docs/api/create-counterparty) and [`POST /api/v1/invoices`](https://www.vestiarion.xyz/docs/api/create-invoice). Each takes a JSON body of at most 64 KB, checked by the console form's rules, and answers `201` with the record, shaped as its list returns it. A body that does not validate, including one with a field the operation does not take, answers `400 invalid_request`, naming the field. A `counterpartyId` the workspace does not hold answers `400` too. - **A new scope, `write`.** A key is read-only, as every existing key stays, or reads and writes; an owner or admin chooses when creating it. A read-only key on a write answers `403 forbidden`. `api_key_created` lists `scopes: ["read", "write"]` for such a key. - **`Idempotency-Key`** on both writes. A repeat with the same key and body within 24 hours gets the first answer back, with `Idempotent-Replayed: true`. The same key with a different body, or while the first request is still being handled, answers the new error code **`conflict` (`409`)**. A `5xx` answer, and a body that fails validation, are not kept, and a key whose first request never finished is free again after 10 minutes. - Writes are limited to 30 a minute per key; over that, `429 rate_limited` with `Retry-After: 60`. Reads stay unlimited. - `create_counterparty` and `create_invoice` entries for records added through the API carry `via: "api"` and `apiKeyId`, with `by` the key's issuer, or `null` once their account is gone. Such a `create_counterparty` also carries `addressNeedsConfirmation`: an address added through the API waits for a person to confirm it, and the agent holds payments to it (`counterparty.address_unconfirmed`) until then. - A payable added through the API starts a cycle, whose `cycle_complete` entry lists `invoice_added` in `events`, as one added in the console does. - **MCP:** two new tools, `create_counterparty` and `create_invoice`, annotated `readOnlyHint: false`, `destructiveHint: false`, `idempotentHint: false`, with an optional `idempotencyKey`. A read-only key gets the `403` as a tool error. - The OpenAPI document describes each write's request body, its `Idempotency-Key` header and its `201`. ## 2026-10-03: A sweep's yield is counted over how long the cash would stay - In treasury entries (`sweep_to_usyc`, `redeem_from_usyc`, `hold`, actor `agent`), `economics.expectedHoldDays` is now how long the swept cash would stay in the reserve, on average over the next 30 days. What falls due is paid from the operating wallet first, and only what it cannot cover comes back, on its day. Before, it was the days until the next obligation. `economics.projectedYieldUsd` follows, and the written policy's `referenceDecision` with it. ## 2026-10-03: Code bounds the treasury's moves, and a payable to a client waits for a person - A new value of `guardrailRule`: `counterparty.client_payable`. The agent holds a payment, or a schedule, to a counterparty whose role is `client`, for a person: a client pays the business, so a payable to one is a refund, or an invoice entered in the wrong direction. A person may still approve it. - Treasury entries (`sweep_to_usyc`, `redeem_from_usyc`, `hold`, actor `agent`, domain `treasury`): - `decision` is now the move code allowed. A sweep takes at most the cash above the 7-day buffer. A redemption brings back at most 115% of what falls due within 14 days, less the operating balance, and at least the written policy's. - When code changed the model's move, `boundedByCode: { chosen: { action, amount }, reason }` keeps what the model chose and why, and `decision.reasoning` ends with "[Code limited this: …]". - `agreedWithReference` now also needs the amount within 5% of `referenceDecision.amount`, or 0.01 USDC. - `ar_reminders_on` gains `replacedLink`: true only when turning reminders on replaced a link made before October 3, which stops working. `madeNewLink` is also true when the receivable had no link. ## 2026-10-03: The agent reminds clients of what they owe - **New ledger actions**, domain `ar`, delivered to webhooks like every entry: - `ar_reminders_on` and `ar_reminders_off`, actor `human`: an owner or admin turned the agent's reminders on or off for a receivable. `detail` is `{ by, invoiceId, counterpartyId }`, and for `ar_reminders_on` also `linkId` and `madeNewLink` (a link made before October 3 was replaced so the reminders can carry it). - `ar_reminder_sent`, actor `agent`: the agent emailed the client a reminder. `detail` is `{ invoiceId, counterpartyId, to, number, tone, daysFromDue, amount, currency, linkId, decision, referenceDecision, decisionMode, agreedWithReference }`. `to` is the address with most of its name hidden; `number` is 1 to 4; `tone` is `friendly`, `firm` or `final`; `daysFromDue` is negative before the due date. `toneLimited: { chosen, sent }` is there when code sent a softer tone than the model chose. - `ar_reminder_deferred`, actor `agent`: the agent decided to wait before reminding. `detail` is `{ invoiceId, counterpartyId, until, daysFromDue, remindersSent, decision, referenceDecision, decisionMode, agreedWithReference }`. - Turning reminders on starts a cycle; its `cycle_complete` entry lists the new event kind `reminders_on` in `events`. - A cycle's journal has a new stage, `collections`, between `proposals` and `notices`. It runs only when `receipts` completed. - `counterparty_notice_email_changed` now covers a counterparty's billing email, which also receives a client's reminders; its `summary` reads "Billing email for *name*: *address*", or "... removed". ## 2026-10-03: The agent's decisions in Telegram - **New ledger actions**, domain `system`, delivered to webhooks like every entry: - `telegram_connected`, actor `human`: a member connected their own Telegram chat to the workspace, from **Members**. Its `detail` is `{ by, username }`, where `username` is the Telegram username with most of it hidden (`@li***`), or `null`. - `telegram_disconnected`: a chat no longer gets the workspace's decisions. Actor `human` when the member disconnected it, from **Members** (`via: "members_page"`) or with `/disconnect` in the chat (`via: "telegram"`); actor `system` when Telegram answered that the member had blocked the bot (`via: "blocked"`). Its `detail` is `{ by, userId, via, username }`, with `by` `null` for `blocked`. A member removed from the workspace loses the connection with the membership, and that is recorded as the removal. - `create_invoice` entries gain `via: "telegram"` when a member added the payable by tapping **Add** on an invoice the bot read. Such an entry carries `document` as one added from a document on AP / AR does, with `changed: []`. - A cycle's journal has a new stage, `telegram`, after `notices`, the last. It needs no other stage to have succeeded. - Nothing changes in `/api/v1`. ## 2026-10-03: Cash comes back from the reserve for payments - **New ledger action** `cash_brought_back`, domain `treasury`, delivered to webhooks like every entry: USDC came back from the reserve to the operating wallet. - Actor `agent`: each cycle, before it decides payments, the agent brings back what the USDC payables due today need beyond the operating balance. Its `detail` is `{ reason: "payments_due_today", amount, neededUsdc, payments, operatingBalance, reserveBalance, executed, executionNote, earnMode, execution? }`. `execution` holds the transactions of a real USYC redemption. When nothing could move, `executed` is `false`, `executionNote` says why, and the entry's `summary` begins "Could not bring"; while the agent is paused, `heldBecause` is `agent_paused`. - Actor `human`: an owner or admin chose **Bring cash back**. Its `detail` is `{ by, reason: "person", amount, all, reserveBalance, earnMode, execution? }`, where `all` says they brought back everything. It moves cash even while the agent is paused, and starts a cycle, whose `cycle_complete` entry lists the new event kind `cash_returned` in `events`. - The redemption itself is also a `treasury_actions` row with `action: "redeem_from_usyc"`, as the treasury stage's are. - An AP decision held because the cash was not there carries `execution.heldBecause: "cash_shortfall"`, with `execution.cashNeededUsdc` (what it needed, with the payables due before it) and `execution.cashSeen: { operating, reserve }` (the balances it saw). Once cash has come in since and covers it, the next cycle reopens the payable with an `invoice_reopened` entry whose `followUp.changes` reads "the cash it needs is there now (… USDC in the operating wallet and the reserve)", and decides it again. - A cycle's journal has a new stage, `liquidity`, between `services` and `ap`. ## 2026-10-03: Payment notices - **New ledger actions**, delivered to webhooks like every entry: - `payment_notice_sent`, actor `system`, domain `ap` or `contractor`: a payee was emailed that a payment to it was confirmed. Its `detail` is `{ invoiceId or milestoneId, counterpartyId, to, amount, token, txHash }`, where `to` is the address with most of its name hidden (`li***@example.com`). - `counterparty_notice_email_changed`, actor `human`, domain `compliance`: a person set, changed or cleared where a counterparty's payment notices go. Its `detail` is `{ by, counterpartyId, from, to }`, both addresses hidden the same way, `null` for none. - `create_counterparty` entries gain `noticeEmail`, hidden the same way, or `null`. - The address itself is not part of `/api/v1/counterparties`. ## 2026-10-03: Add what a held payable was missing - **New ledger action** `invoice_details_added`, actor `human`, domain `ap`, delivered to webhooks like every entry: a person added the purchase order a held, flagged or awaiting-information payable lacked, or marked its goods or services received. Its `detail` is `{ by, invoiceId, counterpartyId, added }`, where `added` holds `poReference`, `goodsReceived: true`, or both. Nothing already on the invoice changes, and its `status` stays as it was. - At its next cycle the agent reopens the payable with an `invoice_reopened` entry whose `followUp.changes` names what was added, and decides it again. Adding details starts that cycle within seconds, and its `cycle_complete` entry lists the new event kind `details_added` in `events`. - In `/api/v1/invoices`, the payable's `poReference` and `goodsReceived` show what was added. ## 2026-10-03: A sandbox screens against the bundled watchlist - `GET /api/v1/status` reports `screening: "simulate"` for a sandbox workspace, whatever the deployment configures; a live workspace reports `live` when OpenSanctions is configured, as before. - A sandbox's counterparties are screened against the bundled watchlist at every cycle, and their `compliance_sweep` entries name that source. One screened by OpenSanctions before is screened again against the watchlist at its next cycle. ## 2026-10-03: Nothing is paid to a counterparty never screened - A new value of `guardrailRule`: `counterparty.unscreened`. The agent holds a payment, a schedule or a milestone release to a counterparty whose `riskLevel` is still `unscreened`, because screening could not run when it was added. The decision is made again once screening gives a verdict. A counterparty whose later screening fails keeps its earlier verdict, as before. ## 2026-10-03: The spending limit, enforced on Arc - **New ledger actions**, actor `human`, domain `system`, delivered to webhooks like every entry: - `spending_limit_enforced`: a person enforced the agent's spending limit on Arc testnet. Its `detail` is `{ by, contract, agent, treasury, dailyUsdc, weeklyUsdc, deployTxHash, approveTxHash, setLimitsTxHash }`, where `agent` is the agent's own wallet and `treasury` the operating wallet. - `spending_limit_unenforced`: a person turned it off. Its `detail` is `{ by, contract, txHash }`. - `agent_budget_changed` gains `onChain: { contract, txHash }` when the figures were changed on the contract too. - While the limit is enforced, the agent's payment decisions (`ap_*` and `milestone_*` entries, actor `agent`) carry `onChainLimit: { contract, agent, ref, covered, uncoveredBecause?, verdict }`. `covered` says whether the payment can go through the contract; `verdict` is the contract's answer before anything was sent: `{ state: "allowed" }`, `{ state: "refused", error, spent?, amount?, limit? }`, `{ state: "unreadable", reason }`, or `null` when it was not asked. - Two new values of `guardrailRule`: `workspace.onchain_limit` (the contract would refuse the payment) and `workspace.onchain_limit_route` (the payment is in EURC, or to another chain, which the contract cannot carry). - A payment sent through the contract has, as its transaction, a call to the contract's `pay(address,uint256,bytes32)` by the agent's wallet. Its logs hold the USDC transfer from the operating wallet to the payee. ## 2026-10-03: A workspace's only approver may approve what they entered - When the person who entered a payable, or added a milestone, is the only member of the workspace who may approve payments (the only owner, admin or approver), they may now approve it themselves. The `approval_paid` or `milestone_approval_paid` entry then carries `soleApprover: true` in its `detail`, and its `summary` ends "(entered and approved by the workspace's only approver)". An approval by anyone else carries neither. Once a second member may approve, the person who entered a record cannot approve it again. ## 2026-10-02: The agent's reasoning is written in plain English - New decisions' `agentReasoning` in `/api/v1/invoices` and `/api/v1/milestones`, and `detail.decision.reasoning` in ledger entries, are written as 2 to 4 plain-English sentences, without the field names of the facts the agent was shown. Nothing about earlier records changes: the API and the ledger return what was stored. ## 2026-10-02: One review dismisses every screening match - A `screening_match_dismissed` entry can now dismiss several matches at once: the ones the person reviewed together. Its `detail` gains `matchedEntities: [{ id, caption, score }]`, every match it dismissed. `matchedEntityId`, `matchedCaption` and `matchedScore` still name the counterparty's verdict match, the first in the list. - Screening now reads every candidate it needs. Previously, after five dismissals of one name, a re-screen could read `clear` while further matches were never looked at. Such a counterparty may now be screened `medium` or `high` again, with a `risk_level_changed` entry. ## 2026-10-02: A person decides a held milestone - **Milestones can be `closed`.** In `/api/v1/milestones`, `status` may be `closed`: a person closed the milestone without paying it, and it is never paid after. `?status=closed` lists them. Every milestone has two new fields, `null` unless it is closed: - `closedAt`: when it was closed; - `closeReason`: the reason the person gave. - **New ledger actions**, delivered to webhooks like every entry: - `milestone_approval_paid` (actor `human`, domain `contractor`): a person paid a held milestone. Its `detail` is `{ by, milestoneId, counterpartyId, amount, currency, overrode, heldFor, txRef, status, attempt?, retriedAfter? }`. - `status` is `paid`, `verified` (submitted, not yet confirmed) or `held` (the transfer failed). - `heldFor` says what it was held for: for example `transfer_failed`, `agent_held` or `outflow_budget`. - `retriedAfter` is present when Circle had failed the previous attempt, which this one sent again. - `milestone_closed` (actor `human`, domain `contractor`): a person closed a held milestone without paying it. Its `detail` is `{ by, milestoneId, counterpartyId, amount, currency, heldFor, reason }`. ## 2026-10-02: The agent buys payee history over x402 - **New public endpoint:** `GET /api/x402/payee-history?address=0x…`. It is paid per call over x402: 0.001 USDC on Arc testnet (`eip155:5042002`), scheme `exact`, settled through Circle Gateway batching (`GatewayWalletBatched`, GatewayWallet `0x0077777d7EBA4688BDeF3E311b846F25870A19B9`). - Without a `PAYMENT-SIGNATURE` header it answers `402` with the offer in `PAYMENT-REQUIRED`. - With a payment Circle's facilitator verifies and settles, it answers `200` with `{ address, workspacesPaid, paymentsConfirmed, firstPaidAt, lastPaidAt, asOf, seller, priceUsdc }` and the settlement in `PAYMENT-RESPONSE`. - A missing or malformed address answers `400` before any offer. When the history cannot be read, it answers `503` and nothing is charged. - **New ledger actions:** - `service_purchased` (actor `agent`, domain `compliance`). Its `detail` is `{ purchaseId, counterpartyId, address, seller, priceUsdc, payer, payTo, nonce, settlement, result }`. - `service_purchase_refused` (actor `agent`, domain `compliance`). Its `detail` is `{ purchaseId, counterpartyId, address, seller, rule, reason }`. `rule` is one of `seller.not_allowed`, `offer.mismatch`, `price.above_max`, `budget.day` or `purse.short`. - `service_purchase_failed` (actor `agent`, domain `compliance`). Its `detail` is `{ purchaseId, counterpartyId, address, seller, reason }`. - `service_budget_funded` (actor `human`, domain `treasury`). Its `detail` is `{ by, amountUsdc, signer, depositor, txHash, balanceUsdc }`. - AP decision entries and `milestone_release` / `milestone_hold` entries carry `observed.addressHistory` when the agent bought the counterparty's address history within 7 days: `{ about, workspacesPaid, paymentsConfirmed, firstPaidAt, lastPaidAt, boughtAt, priceUsdc }`. - A cycle's `detail.stages` includes `services`, which runs after `recurring` and before `ap`. ## 2026-10-02: Batches through the operating wallet's own executeBatch - A batch of milestones is now one call to the operating wallet's own `executeBatch`, as Circle documents for its smart accounts. The wallet sends USDC to each payee in turn, and if one transfer fails, none happens. - The first version sent batches through Arc's Multicall3From. Multicall3From keeps the sender only when the sender signed the transaction itself, and a Circle smart account's transaction is sent by Circle's bundler. Circle refused that batch at estimation (`ESTIMATION_ERROR`), and no money moved. Its milestones were held, and their `milestone_release` entries carry `execution.batch` with `resultingStatus: "held"`. - For a short time in between, workspaces with live wallets paid each milestone alone. ## 2026-10-02: Several milestones in one transaction - When a cycle releases two or more milestones paid in USDC on Arc testnet, they go out in one transaction. Each is still decided, checked and signed on its own. A milestone locked in escrow is always released on its own. - A `milestone_release` entry for a milestone paid this way carries `execution.batch`: `{ key, size }`. `key` identifies the batch, and `size` is how many milestones it paid. - `execution.txRef` is the batch's transaction, shared with the other milestones in it. - `execution.feeUsd` is this milestone's share of the transaction's fee. - In `/api/v1/milestones`, several milestones can have the same `tx_ref`. A transaction hash no longer identifies one payment: key on the milestone's `id`. ## 2026-10-02: The agent proposes limits from people's approvals - When people approve at least two payments to one counterparty above its payment limit within 30 days, and reject none, the agent may propose raising the limit. The counterparty must be screened clear. An owner or admin accepts or dismisses the proposal in Approvals. - **New ledger actions**, in the `compliance` domain: - `policy_proposal_made` (actor `agent`). Its `detail` is `{ proposalId, counterpartyId, kind, fromLimit, toLimit, evidence, reasoning, decisionMode, referenceLimit, agreedWithReference }`. `kind` is `raise_limit`. `evidence` lists the approvals it rests on: `{ invoiceId, amountUsdc, approvedAt, approvedBy, agentAction, guardrailRule }`. - `policy_proposal_declined` (actor `agent`): the model decided not to propose. Its `detail` is `{ counterpartyId, currentLimit, evidence, reasoning, decisionMode, agreedWithReference }`. - `policy_proposal_accepted` (actor `human`). Its `detail` is `{ by, proposalId, counterpartyId, fromLimit, toLimit, currentLimit }`. A `counterparty_limit_changed` entry precedes it, as for any limit change. - `policy_proposal_dismissed` (actor `human`). Its `detail` is `{ by, proposalId, counterpartyId, toLimit }`. - A cycle's `detail.stages` includes `proposals`, which runs last, after `forecast`. ## 2026-10-02: Recurring payments - An owner or admin can set up a payment that repeats every N days, weeks or months. Each period's invoice is created as it comes near and decided like any other. - **New ledger actions**, in the `ap` domain: - `recurring_payable_created` (actor `human`). Its `detail` is `{ by, recurringId, counterpartyId, amount, currency, memo, poReference, goodsReceived, everyCount, everyUnit, startsOn, endsOn }`. - `recurring_invoice_created` (actor `agent`), when a cycle creates a period's invoice. Its `detail` is `{ invoiceId, recurringId, period, counterpartyId, amount, currency, goodsReceived }`. `period` is the invoice's due date, `YYYY-MM-DD`. - `recurring_payable_stopped` (actor `human`). Its `detail` is `{ by, recurringId }`. - A cycle's `detail.stages` includes `recurring`, which runs after `follow_up` and before `ap`. - An event cycle's `detail.events` can include **`recurring_added`**: someone set up a recurring payment. - Two invoices created for different periods of one recurring payment are never reported as duplicates of each other. Their decision entries' `observed.duplicateCheck` reflects that. ## 2026-10-02: A real USYC reserve - A live workspace's owner or admin can turn on a real USYC reserve on Arc testnet, once Circle has allowlisted its operating and reserve wallets. Until then, the reserve stays simulated, as before. - **New ledger action `usyc_reserve_enabled`**, in the `treasury` domain, appended when the reserve is turned on. Its `detail` is `{ by, operatingAddress, reserveAddress }`. - **Treasury decision entries** (`sweep_to_usyc`, `redeem_from_usyc`, `hold`) of a workspace with a real reserve: - carry `earnMode: "live"`; - carry `usycSubscriptionsOpen`: whether USYC could be bought then, or `null` when that could not be read; - when a move was made, carry `execution`. A sweep's is `{ approveTxHash, depositTxHash, shares, price }`; a redemption's is `{ redeemTxHash, shares, price }`. `shares` is in USYC, `price` in USDC per USYC. - A sweep decided while USYC cannot be bought has `executed: false`, and an `executionNote` saying so. - In `GET /api/v1/status`, `data.provenance.yield` reads `live` for such a workspace. - The reserve account's `balance` is its USYC's value in USDC at the latest price, read from Arc testnet every cycle. Its `apy` is the fund's yield over at least the last 5 days, from the same oracle. ## 2026-10-02: The agent's spending limit - An owner or admin can set what the agent may pay on its own per UTC day and per 7 days, in USDC. A payment past either is held for a person. - **New value of `guardrailRule`, `workspace.outflow_budget`**, on an `ap_pay` or `milestone_release` entry: paying it would have taken the agent past its limit. - The entry's `execution.heldBecause` is `outflow_budget`. - A payable goes to Approvals as `held`. A milestone stays `held` until the limit has room for it. - **New field `outflowBudget`** on an `ap_pay` or `milestone_release` entry, present when a limit is set: `{ dailyUsdc, weeklyUsdc, spentToday, spentThisWeek, remaining, binding }`. - It is what the limit left when the payment was weighed. - `binding` is `day` or `week`, whichever figure left less. - **Milestone decision entries now carry `guardrailRule`**: `counterparty.high_risk`, `counterparty.payment_limit`, `workspace.outflow_budget`, or `null`. - **New ledger action `agent_budget_changed`**, in the `system` domain, appended when someone changes the limit. - Its `detail` is `{ by, from, to }`. `from` and `to` are each `{ dailyUsdc, weeklyUsdc }`, and `null` means no figure. - What counts against the limit is what the agent's own `ap_pay` and `milestone_release` decisions sent: entries whose `execution.resultingStatus` is `paid` or `matched`, at their USDC value. A payment a person approves does not count. - `invoice_reopened` and `milestone_reopened` entries can list the change "the agent's spending limit has room for it again" or "the agent's spending limit was removed". - An event cycle's `detail.events` can include **`budget_raised`**: someone raised or removed a figure of the limit. ## 2026-10-01: Receivables paid on Arc - **New ledger action `pay_link_created`**, in the `ar` domain: an owner or admin made a link for a client to pay a receivable. Its `detail` is `{ by, invoiceId, linkId }`. - **New ledger action `ar_received`**, in the `ar` domain, appended by the agent when a transfer that arrived in the operating wallet settles an open receivable. - Its `detail` is `{ invoiceId, counterpartyId, amount, currency, txHash, from, circleTxId, matchedBy, receivedAt }`. - `matchedBy` is `sender` when the client's address on file sent it, and `amount` when it was the only open receivable of that currency and amount. - A receivable's `status` becomes `received`, with `settledAt` and `txRef` set, when a transfer settles it. - A cycle's `detail.stages` includes `receipts`, which runs after `reconcile`. - An event cycle's `detail.events` can include **`payment_received`**: a client's "I have paid" check found the receivable paid. ## 2026-10-01: Dismissing a screening match - **New ledger action `screening_match_dismissed`**, in the `compliance` domain, delivered as a `ledger.appended` event. It is appended when a member who can decide approvals dismisses a counterparty's screening match as not the same person. - Its `detail` is `{ by, counterpartyId, matchedEntityId, matchedCaption, matchedScore, screenedName, reason }`. - `matchedEntityId` is the screening service's id for the entity dismissed; `screenedName` is the counterparty's name when it was dismissed. - Screening skips a dismissed entity for that counterparty while its name stays the same, and judges the next match, if any. The counterparty is screened again at once, so a `screen_counterparty` entry with its new verdict follows. - An event cycle's `detail.events` can include **`match_dismissed`**. ## 2026-10-01: Held milestones decided again - **New ledger action `milestone_reopened`**, in the `contractor` domain, delivered as a `ledger.appended` event. The agent appends it when it returns a held milestone to `verified` because a fact its decision rested on changed: the contractor's risk level or payment limit, the milestone's verification source, or the agent was paused when it held it. - Its `detail` is `{ milestoneId, previousStatus, followUp: { action, reason, changes } }`. `previousStatus` is `held`, `followUp.action` is `reopen`, and `changes` lists each fact that moved, in words. - The milestone's next decision entry follows in the same cycle. - A milestone's `status` can now go from `held` back to `verified` without anyone verifying it again. - An event cycle's `detail.events` can include **`limit_raised`**: someone raised a counterparty's payment limit and screening allows more than zero. ## 2026-10-01: EURC payments funded by a swap - A live workspace can pay a EURC payable after swapping USDC for the EURC it needs, through Circle's Stablecoin Service on Arc testnet. - **New ledger action `fx_swap`**, in the `treasury` domain, delivered as a `ledger.appended` event when a swap ends. - Its `detail` is `{ paysInvoiceId, swapId, state, usdcIn, eurcMinimum, eurcEstimated, eurcReceived, usdcPerEurc, costPercent, provider, adapter, approveTxHash, swapTxHash, failure, reasoning, resumed }`. - `state` is `confirmed` or `failed`. - `eurcReceived` is the EURC the swap's transaction sent to the operating wallet, read from its receipt, or `null` when it could not be read. - `resumed` is `true` for a swap whose first answer was lost, finished by a later cycle. Its `reasoning` is then `null`. - It carries `paysInvoiceId`, not `invoiceId`: it is not a decision on the invoice. - **New fields on a EURC payable's decision entry:** - `usdcDueWithin7Days`; - `swapOffer`: `{ usdcIn, eurcEstimated, eurcMinimum, usdcPerEurc, costPercent, provider }` when a swap was offered, or `null`; - `swapUnavailable`: why there was none, or `null`; - `swap`: `{ swapId, state, usdcIn, eurcReceived, swapTxHash, reason }` for the swap made to fund the payment, or `null`. `state` is `confirmed`, `pending` or `failed`. - `decision.fundWithSwap` is `true` when the model chose to pay with the swap. It appears only on a payable the wallet's EURC was short of. - `swapOffer.costPercent` is what each EURC costs through the swap above the rate the payable was weighed at, in percent, rounded up to two places. - **Two new values of `guardrailRule`:** - `fx.swap_cost_above_cap`, for a swap costing more than 3% above the quoted rate; - `fx.swap_usdc_short`, for a swap that would leave the USDC below what falls due in USDC within 7 days. - **When a swap does not go through:** - A failed swap holds the payable with `guardrailRule: null`, as a failed transfer does. Its reason is in `swap.reason`. - A swap still in flight at Circle leaves the payable `pending`. The next cycle finishes the swap, then decides the payable again. ## 2026-10-01: Milestone escrow - A live workspace can lock a milestone's USDC in its own escrow contract on Arc testnet. Three new ledger actions, in the `contractor` domain, are delivered as `ledger.appended` events: - `escrow_deployed`, once per workspace. Its `detail` is `{ by, address, payer, deployer, circleContractId, txHash }`. - `escrow_funded`, when a milestone is locked. Its `detail` is `{ by, milestoneId, contract, holdId, payee, amountUsdc, refundAfter, fundTxHash }`. `fundTxHash` is `null` when the hold was found already on chain and recorded. - `escrow_refunded`, when a hold is taken back after its refund date. Its `detail` is `{ by, milestoneId, contract, holdId, amountUsdc, refundTxHash }`. - A milestone locked in escrow is paid by releasing its hold rather than by a transfer. Its release decision's `execution.txRef` is that release transaction on Arc testnet. ## 2026-10-01: Payment receipts - An owner or admin can share a paid payable's receipt as a public link. Three new ledger actions, in the `ap` domain, are delivered as `ledger.appended` events: - `receipt_shared`, appended once per payment, the first time it is shared. Its `detail` is `{ receipt, records }` and holds no names and no user ids: - `receipt` is `{ amount, token, paidAt, payee, chain, txHash, route, sourceTxHash?, feeUsdc? }`; - `chain` and `txHash` are where the payee was paid: the transfer on Arc testnet, or the mint on the payee's chain; - `route` is `direct`, `cctp` or `gateway`; - `sourceTxHash` is a CCTP payout's burn; - `records` is `{ seq, hash }` of the entry that recorded the payment. - `receipt_link_renewed`, when a new link replaces an earlier one or a revoked receipt is shared again. Its `detail` is `{ by, receiptId }`. - `receipt_revoked`, when a receipt's link is turned off. Its `detail` is `{ by, receiptId }`. - None of the three carries `invoiceId` in `detail`: a receipt's entries are about sharing a payment, not deciding an invoice. ## 2026-10-01: Payouts from a Gateway balance - A payout to a payee on another chain can go from the workspace's Circle Gateway balance as well as through CCTP. Its decision entry's `payout.route` is then `gateway`, `payout.feeUsdc` is Gateway's fee, and `payout.gatewayBalanceUsdc` is the Gateway balance it was weighed against. - A decision entry for a USDC payout to another chain also carries `payout.quotes: { cctpFeeUsdc, gatewayFeeUsdc }`: both routes' fees as read for that decision, each `null` when there was none. `gatewayFeeUsdc` is `null` for a workspace with no Gateway balance. - An `approval_paid` entry for a new payment to another chain carries the same `payout`, read when the person approved: `{ chain, route, domain, feeUsdc, quotes }`, with `gatewayBalanceUsdc` when the route is `gateway`. An approval that only records a transfer already sent carries none. - A payout keeps the route its first attempt took. A new value of `guardrailRule`, `bridge.gateway_balance_short`, holds one that went through Gateway when the Gateway balance no longer covers it with its fee. - For a Gateway payout: - the decision entry's `execution.txRef` is the mint on the payee's chain, with `execution.destinationChain` and `execution.mintTxHash`. No Arc transaction belongs to one Gateway payout: Gateway burns on Arc in batches; - `GET /api/v1/invoices` returns that mint as the invoice's transaction once it is paid. Until then the invoice is `matched`, as a CCTP payout is. - Three new ledger actions, in the `treasury` domain, delivered as `ledger.appended` events: - `gateway_signer_created`, whose `detail` is `{ by, signer, circleWalletId }`; - `gateway_delegate_added`, whose `detail` is `{ by, depositor, signer, txHash }`; - `gateway_deposit`, whose `detail` is `{ by, amountUsdc, depositor, signer, approveTxHash, depositTxHash, balanceUsdc }`. `balanceUsdc` is the Gateway balance once it includes the deposit, or `null` when Gateway had not counted the deposit yet. ## 2026-10-01: Invoices read from a document - A `create_invoice` entry for an invoice read from a document carries `document` in its `detail`: - `kind`: `pdf`, `email` (an `.eml` file: its text and the text of its PDF attachments) or `text`; - `sha256`: the hash of the document's bytes, or of the pasted text; - `reader`: the model that read it (`anthropic`, `openai`, `deepseek`), or `heuristic` for the rule-based reader; - `changed`: the form fields the member changed from what was read, among `amount`, `currency`, `dueDate`, `poReference`, `earlyPayDiscountPct`, `discountDeadline`, `memo` and `counterpartyId`. - An invoice typed in carries no `document`. The document itself is not stored. ## 2026-10-01: First screens in the compliance sweep - Each entry in a `compliance_sweep` entry's `detail.screened` now carries `firstScreen`: `true` when the counterparty had no verdict before. `changed` is still `true` for a first screen. - When a sweep includes first screens, its `summary` counts them apart: "Screened 6 of 6 counterparties: 6 for the first time, 0 changed". A sweep of re-screens only still reads "Re-screened 6 of 6 counterparties; 0 changed". ## 2026-10-01: Payees on other chains - A counterparty's `chain` is one of `ARC-TESTNET`, `BASE-SEPOLIA`, `ARB-SEPOLIA` or `ETH-SEPOLIA`. `GET /api/v1/counterparties` returns it as before. - A payee on a chain other than Arc testnet is paid from Arc through CCTP V2 with Circle's Forwarding Service. - Its decision entry (`ap_pay`, `ap_hold`, `ap_flag_fraud`, `ap_request_info`, `ap_schedule`) carries `payout: { chain, route: "cctp", domain, feeUsdc }` in `detail`, where `chain` is the chain id (`BASE-SEPOLIA`) and `feeUsdc` is `null` when Circle gave no fee. - Only a vendor can be paid on another chain; a contractor's milestones are released on Arc testnet. - Its `execution` carries `destinationChain` and `mintTxHash` once the payment was sent. `execution.txRef` is the burn on Arc testnet, or `cctp:` while the burn has no hash yet. - `mintTxHash` is `null` until the Forwarding Service mints. A later `ap_reconcile` entry carries it. - Three new values of `guardrailRule`, each of which holds the payable for a person: - `bridge.fee_above_cap`, when the fee is above 10% of the amount; - `bridge.fee_unavailable`, when Circle gave no fee; - `bridge.unsupported_token`, for an invoice in EURC to a payee on another chain. - A bridged invoice is `matched` from the burn until the mint, then `paid`. `GET /api/v1/invoices` shows it so. One still not minted two hours after it was sent is `held` for a person, by an `ap_reconcile` entry saying so. ## 2026-10-01: Invoices in EURC - An invoice can be in EURC as well as USDC. `GET /api/v1/invoices` returns its `currency`, which is now `USDC` or `EURC`; `amount` and `paidAmount` are in that currency. - A EURC payable is paid in EURC from the operating wallet. Its decision entry (`ap_pay`, `ap_hold`, `ap_flag_fraud`, `ap_request_info`, `ap_schedule`) carries `currency` in `detail`, and, for EURC only: - `usdcValue`, its value in USDC, which is what counts against the counterparty's limit; - `fx: { rate, source: "circle-stablecoin-quote", quotedAt, usdcMinimum }`, or `null` when Circle gave no quote; - `eurcBalance`, the operating wallet's EURC as read for the decision, or `null` when payments are simulated or the balance could not be read. Those five decision entries carry `currency: "USDC"` for a USDC invoice. `ap_reconcile` carries no `currency`. - Two new values of `guardrailRule` for a EURC payable, both of which hold it for a person: `fx.rate_unavailable`, when there was no quote to weigh it at, and `treasury.insufficient_eurc`, when the wallet's EURC cannot cover the payment or could not be read. As with every rule, `guardrailRule` is set when code refuses a payment or schedule the model decided on; a payable the model held itself for the same reason has `guardrailRule: null`, with `usdcValue: null` or an `eurcBalance` below `observed.amount` in its `detail`. - A duplicate is the same amount only in the same currency. A purchase order billed in one currency and again in the other is reported as `purchase_order_rebilled`, which goes to the model and does not block on its own. - `approval_paid` and the `create_invoice` and `import_invoice` entries gain `currency` in `detail`, and their summaries name it. - The invoice CSV import takes an optional `currency` column. A blank or missing value means USDC. ## 2026-10-01: Milestones added in the app - A member can now add a contractor milestone on the Contractors page. It starts `pending` and unverified, like any milestone in `GET /api/v1/milestones`. - Its `verificationSource` is the evidence link the member gave, or `null`: a GitHub pull request in its canonical form (`https://github.com///pull/`, which the cycle checks for a merge as before), or any other `https://` address, which only a person verifies. - A new ledger action, delivered as a `ledger.appended` event: `create_milestone`, in the `contractor` domain with `actor: "human"`. Its `detail` is `{ by, milestoneId, counterpartyId, counterpartyName, amount, verificationSource }`, with `amount` as the decimal string entered. - A new event kind, `milestone_added`, may appear in an event cycle's `detail.events`. It is raised only for a milestone whose evidence is a pull request, so the merge is checked within a minute. No `/api/v1` endpoint, parameter or response schema changed. ## 2026-10-01: Payment timing - An invoice can carry early-payment terms: a percent off and the date it lapses, set on the invoice form or by CSV import. `GET /api/v1/invoices` now returns `earlyPayDiscount: { percent, deadline } | null` for any invoice that carries them. - A payable the agent has committed to pay later reaches status `scheduled`, decided again each cycle until the day it pays. `GET /api/v1/invoices`'s `status` filter now accepts `scheduled`. - `GET /api/v1/invoices` also returns `scheduledFor` (an ISO timestamp, or `null`) and `paidAmount`: what actually left, once an invoice's status is `paid`. `paidAmount` stays `null` before then, even while a submitted transfer already carries an amount. - A new ledger action, delivered as a `ledger.appended` event: `ap_schedule`, in the `ap` domain, recorded when the agent schedules a payable. Its `detail` carries `decision: { action: "schedule", payOn, reasoning, confidence }`, `timing` and `timingRule` (the policy's figures, and any correction made to the model's date), `requestedPayOn` when a date was corrected, `terms: { earlyPayDiscount }`, `observed`, and `execution.resultingStatus: "scheduled"`. - Every `ap_*` decision entry (`ap_pay`, `ap_hold`, `ap_flag_fraud`, `ap_request_info`, `ap_schedule`) now carries `timing`, `timingRule` and `terms` in its `detail`; an entry for a previously scheduled invoice also carries `scheduledFor`. - `ap_pay` and `approval_paid` gain `amountPaid` and `discountTaken` in `detail`: both `null` when nothing went out, or the discounted amount and the percent taken when an invoice was paid by its discount deadline. - `cycle_complete`'s `detail.outcomes` gains `scheduledCount`. - A duplicate is now also refused against a payable already `scheduled`, already being paid (`matched`), or being decided by a person on Approvals (`processing`), not only one already `paid` or `received`. The refusing entry's reasoning names which of these it repeats. ## 2026-09-30: Payee links - Two new ledger actions, delivered as `ledger.appended` events, both in the `compliance` domain with `actor: "human"`: - `payee_link_created`, whose `detail` is `{ by, counterpartyId, linkId, expiresAt }`; - `payee_link_revoked`, whose `detail` is `{ by, counterpartyId, linkId }`. - A `counterparty_address_changed` entry made by a payee through a payee link has `by: null`, and carries `via: "payee_link"` and `linkId` next to `counterpartyId`, `from` and `to`. - Such a change waits for confirmation like any other; a later `counterparty_address_confirmed` records who confirmed it. No `/api/v1` endpoint, parameter or response schema changed. ## 2026-09-30: What started a cycle - A `cycle_complete` ledger entry's `detail` can now carry `trigger`, which says what started the cycle: - `"schedule"` for the six-hourly run; - `"manual"` for **Run cycle now**; - `"event"` when something in the workspace gave the agent a decision to make. - An event cycle's `detail` also carries `events`, the kinds that led to it: `invoice_added`, `milestone_verified`, `payable_returned`, `address_confirmed`, `agent_resumed` and `sample_loaded`. `by`, when present, is the person whose action started the cycle. - These fields are visible on `ledger.appended` events and in `GET /api/v1/ledger`. - A cycle no longer starts while another one in the same workspace is running, so one workspace's cycles do not overlap. No `/api/v1` endpoint, parameter or response schema changed. ## 2026-09-30: Failed-payment retry - An `approval_paid` ledger entry's `detail` can now carry `attempt`, the transfer attempt this approval ended on (starting at 1), and, only when that attempt is a retry, `retriedAfter`: `{ providerTxId, providerState, failureReason }` for the earlier attempt Circle ended in a terminal failure. Both are visible on `ledger.appended` events and in `GET /api/v1/ledger`. - Circle's `STUCK` transfer state is no longer a terminal failure; it is now reported the same way a transfer still `pending` is. Only `CANCELLED`, `DENIED` and `FAILED` end a transfer attempt. No `/api/v1` endpoint, parameter or response schema changed. ## 2026-09-30: Ledger signing key rotation - An owner can rotate a workspace's ledger signing key from Settings. The rotation is a ledger entry, delivered as a `ledger.appended` event like every other: `ledger_key_rotated`, in the `system` domain, with `actor: "human"`. Its `detail` is `{ from, to, by }`: the retired key's id, the new key's id, and the owner who rotated. See [Key identity and rotation](https://www.vestiarion.xyz/docs/api/verify-ledger#key-identity-and-rotation). - From that entry on, `signingKeyId` is the new key's id. Earlier entries keep the old id and keep verifying against the retired key. - `ledgerRetiredKeyCount` in `GET /api/v1/status` now counts the workspace's own retired keys. No `/api/v1` endpoint, parameter or response schema changed. ## 2026-09-30: Counterparty limit changes - A new ledger action, delivered as a `ledger.appended` event: `counterparty_limit_changed`, in the `compliance` domain. Its `detail` is `{ by, counterpartyId, from, to, currentLimit }`, where `from` and `to` are the configured limits in USDC and `currentLimit` is the limit the counterparty's risk now allows. A client's limit may be `null`. No `/api/v1` endpoint, parameter or response changed. ## 2026-09-30: Counterparty address changes - Two new ledger actions, delivered as `ledger.appended` events like every other entry, both in the `compliance` domain: - `counterparty_address_changed`, whose `detail` is `{ by, counterpartyId, from, to }`. `from` or `to` is `null` when there was no address before, or it was cleared. - `counterparty_address_confirmed`, whose `detail` is `{ by, counterpartyId, address, via }`. `via` is `"approval"` when approving a payment confirmed it, and `"confirm"` when someone confirmed it on the counterparty. - An agent decision entry (`ap_pay` and the other `ap_*` actions) can now carry `guardrailRule: "counterparty.address_unconfirmed"`. It means the payment was held because the counterparty's address changed and no one has confirmed it. Its `observed` object gains `addressUnconfirmed`, a boolean. - An `ap_reconcile` entry can now carry `notResubmittedBecause: "counterparty.address_unconfirmed"`, beside the existing `"counterparty.high_risk"`. A payment that never reached the network is not sent again to a changed address that no one has confirmed; it is held for a person. No `/api/v1` endpoint, parameter or response changed. ## 2026-09-29: MCP server - A remote MCP server at `https://www.vestiarion.xyz/api/mcp`, over Streamable HTTP. An AI agent connects with a workspace API key, sent as `Authorization: Bearer `, and reads that workspace's records through one tool per `/api/v1` read operation. A tool returns the operation's JSON exactly. See [MCP server](https://www.vestiarion.xyz/docs/ai-integration/mcp). - A missing, malformed, unknown or revoked key answers `401` with a `WWW-Authenticate` header; a key without the `read` scope answers `403`. - There are no write tools, and no OAuth. No `/api/v1` endpoint, parameter or response changed. ## 2026-09-29: Webhooks sent right after each append - A `ledger.appended` delivery now goes out right after the request that appended its entry (approving a payment, pausing the agent, changing a member, a key or an endpoint), not on the next scheduled dispatch. Entries appended by the agent's scheduled run still go out right after that run. The 10-minute schedule remains for retries. See [Timing](https://www.vestiarion.xyz/docs/webhooks#timing). No payload, header or signature changed. ## 2026-09-29: Developer docs, OpenAPI document and llms.txt - These docs, at [`/docs`](https://www.vestiarion.xyz/docs): a quickstart, guides, and a [reference page](https://www.vestiarion.xyz/docs/api) for every endpoint, with a **Try it** panel that sends a real request with your key. - [`GET /api/v1/openapi.json`](https://www.vestiarion.xyz/api/v1/openapi.json): an OpenAPI 3.1 document for every `/api/v1` endpoint. It is public and needs no key. - A Markdown view of every page at its URL plus `.md`, and [`/llms.txt`](https://www.vestiarion.xyz/llms.txt) and [`/llms-full.txt`](https://www.vestiarion.xyz/llms-full.txt): see [AI integration](https://www.vestiarion.xyz/docs/ai-integration). - **Cursors are checked per endpoint.** A `cursor` that the endpoint could not have issued, such as a ledger cursor sent to `/invoices` or an altered one, now answers `400 invalid_request`. It could reach the database before and answer `500`. See [Pagination](https://www.vestiarion.xyz/docs/get-started/pagination). No other endpoint, parameter or response changed. ## 2026-09-29: Signed webhooks - An owner or an admin can add up to 5 HTTPS endpoints per workspace in Settings. Each one receives a `ledger.appended` event for every new ledger entry, and a `webhook.test` event on demand. See [Webhooks](https://www.vestiarion.xyz/docs/webhooks). - Every delivery is signed with HMAC-SHA256 in the `Vestiarion-Signature` header, using the endpoint's own secret, shown once when it is added. See [Verifying signatures](https://www.vestiarion.xyz/docs/webhooks/verify). - Delivery is at least once and not in order. A failed delivery is retried up to 7 attempts in total, and an endpoint is disabled after 20 consecutive failed attempts. See [Retries and disabling](https://www.vestiarion.xyz/docs/webhooks/retries). ## 2026-09-29: Workspace API keys - `/api/v1` now authenticates with a workspace API key, `vxk__`, sent as `Authorization: Bearer `. An owner or an admin creates keys on the workspace's Settings page; each key is shown once and reads only its own workspace. See [Authentication](https://www.vestiarion.xyz/docs/get-started/authentication). - **Breaking:** the platform token, `AGENT_API_TOKEN`, no longer opens `/api/v1`, and answers `401`. It had read only the founding workspace. Move any caller that used it to a workspace key. - A missing, malformed, unknown or revoked key answers the same `401 unauthorized`. A key whose scopes do not cover a route answers `403 forbidden`. - `GET /api/v1/status` reports the calling key's own workspace, and its `configuration` no longer includes the deployment's `database` block.