The first test sale
This page describes @nuanu-ai/agentify 0.11.2. A change to the public contract arrives in a new package version.
You are the one writing code here: the business has an API, and you want to keep the delivery in your own hands. This page walks from an empty project to a test sale you make yourself in the test environment, where the money is not real: you publish a card, your handler takes the order and delivers, the buyer receives the goods, and a receipt is written. You do not rewrite the shop to get there: a handler appears beside it, in a process of yours that takes paid orders and gives out the goods for them. A business that sells from a WooCommerce shop has another route, without code, described on Selling through a WooCommerce shop.
The words used below ​
- A card is the description of a product in the catalogue that an agent decides from and buys through; its fields are in the card reference.
- An order is the object that appears on our side after a purchase and arrives on yours; its whole life is on Orders and fulfillment modes.
- A handler is your code, which receives orders and carries out the delivery, and your process is the program that runs it beside your API.
- An idempotency key is a string that is the same across every repeat of one order. Ours is the order's identifier, and your side answers with the earlier result under it instead of delivering a second time.
- A fulfillment mode says whether the goods arrive in the answer to the purchase or later, which decides when the buyer is charged. Three modes can be published: synchronous, asynchronous, and a parcel (the modes).
- A price check is the question of how much a product costs and whether it is there, which we ask before the buyer pays for a product whose price moves. A price handler answers it, in your process beside the order handler.
- The facilitator is the service that verifies the buyer's signed payment and executes the charge; who is who in a sale is on Connecting to Agentify.
- The pilot is the period Agentify runs in now, defined on Connecting to Agentify; some rules hold only during it.
One purchase, from the catalogue to the goods ​
Your process takes part in each purchase as drawn here, and each step below builds one part of it.
1. Sign in and create a key ​
Open the test dashboard, enter your email address, open the link in the message and press the confirmation button. That first sign-in sets up your business. You reach the same dashboard the same way when you come back. If you are already signed in from a scanner report, the dashboard offers one button instead, "Open my seller dashboard", which sets up your business the same way.
On Products, choose With code. Create an API key there if you do not already have one: give it a name of at most 100 characters that tells it from the next one. Press Create key and copy it from the window. Keep it with your other secrets and out of source control. If you came here from that screen and already copied the key, use it; you do not need another one.
Any key you issue can do everything your code can: publish cards, read orders and receipts, take a card or all selling off sale and put it back, and set your seller name and site. None of them can change the wallet, issue another key or revoke one; only the dashboard does those. So if a key leaks, revoke it on API keys and create a fresh one for your code: once revoked it can do nothing more, and it cannot have left another key of its own behind.
This step worked if you hold a key that starts with csk_test_.
2. Install the package and build a client ​
npm install @nuanu-ai/agentifyIt runs on Node.js 24 or later. The release this page describes is named at
its top, and what changed between releases is in the
changelog.
A release keeps working when Agentify adds to what it sends: a field it does
not know beside an order is left out, an event it does not know is skipped with
a warning, and a status it does not know reads unknown. What an order
delivers is read exactly, so a change there stops your process loudly instead
(When Agentify is newer than your package).
The client needs a key and the address of the channel, and nothing after the
address: it adds the rest of the path itself. The test environment is
https://test.agentify.ad, and a key made on one channel does not open the
other.
import { createClient } from '@nuanu-ai/agentify'
const agentify = createClient({
apiKey: process.env.AGENTIFY_API_KEY,
baseUrl: process.env.AGENTIFY_URL,
})The code on this page, but for the test buyer on step 6, also runs as plain
JavaScript. To try it in an empty project, put it in a file whose name ends in
.mjs, such as agentify.mjs, which lets Node read import and await at
the top of the file, and put the key and the address in a file named .env
beside it:
AGENTIFY_API_KEY=csk_test_...
AGENTIFY_URL=https://test.agentify.adRun it with node --env-file=.env agentify.mjs. The steps below are pieces you
add to that file, not one program to paste whole: grantAccess,
startProvisioning and currentPriceOf stand for your own code that makes the
goods and knows the price, and savedId, url and order in the later
snippets stand for values your code holds at that point.
This step worked if the client was built. A client built wrong is a
TypeError: with no key or with an address that is not an address at the line
that builds it, and with no address at all at its first call. Whether the key
and the address belong together is answered by the first call that reaches us,
on the next step.
3. Start the process that takes orders ​
Your process takes orders from a queue on our side through a subscription it
opens itself, over ordinary HTTPS to the address you gave the client, so it
needs no public address and no open port. You declare what it answers with
on, once for each kind of message (orders, price questions and events about
orders), and then open the subscription with start. Register one handler for
orders, either the synchronous or the asynchronous kind below, and call
start() once.
Register agentify.on('problem', ...) as well. It is where the tools tell you
what did not get through: a poll that failed, a handler that threw, an answer
we would not take, a message nobody handles. Without it those go to the
console, including the one that matters most: when Agentify speaks a version
of the contract your tools do not, the subscription stops and your process
stays up without selling.
While a WooCommerce shop connected in the dashboard sells for you, your process receives no orders and every poll is refused with words that say so, because a merchant sells through one of the two at a time.
A synchronous handler ​
The card you publish on step 4 is synchronous: the handler returns the delivery, or a refusal, built on the order itself, and we send that answer for you.
agentify.on('order', async (order) => {
const access = await grantAccess(order.params.email, {
idempotencyKey: order.id,
})
if (!access.ok) {
return order.refused({
code: 'out_of_stock',
message: 'No seats left on that plan',
})
}
return order.delivered({ access_url: access.url })
})
agentify.on('problem', (problem) => console.error(problem))
await agentify.start()Answer within three seconds of receiving the order where you can: a handler still working after three seconds has the same order sent again, possibly to another instance, and a synchronous sale is lost ten seconds after the payment check, when nothing has been charged (Every time limit). A refusal and its message reach the agent word for word, so write the message for a stranger.
An asynchronous handler ​
A card with fulfillment: 'async' is charged at the moment of purchase, and
its goods come later. The handler starts the work and answers at once that the
order is taken on; write the order's id into your own record of the job and
deliver against it when the work is done, from anywhere in your code.
agentify.on('order', async (order) => {
await startProvisioning(order.params.email, { idempotencyKey: order.id })
return order.accepted()
})
agentify.on('problem', (problem) => console.error(problem))
await agentify.start()
// later, once the delivery is finished, against the id you wrote down:
await agentify.orders.forId(savedId).deliver({ access_url: url })Return accepted within three seconds, which is how long we wait for a
handler's answer. The delivery deadline on your card is already running, from
the charge at purchase. A card that names none is held to a day.
If the delivery cannot be made after you took the order on, say so at once rather than wait out the deadline; the buyer has paid, so the order is then unfulfilled:
await order.refuse({
code: 'out_of_stock',
message: 'The supplier did not confirm the number',
})deliver and refuse return their failures in the same envelope the publish
does, ok and an error, rather than throwing them, unless the client was
built with no address (When a call on an order does not go
through).
A failure that may pass, and a repeat ​
A refusal is final. For a failure that may pass, such as a supplier that did not answer or a database that was down, throw instead: the order counts as never having reached you and comes again after a delay, and what you threw goes to your problem handler and never to the agent.
Orders are delivered at least once, so the same order can reach your handler
again: after a network break, after your process restarted, after a retry of
ours. Pass order.id on as the idempotency key of your own delivery system and
answer a repeat with the result you gave the first time (Telling a repeat
apart). The identifier is an ordinary
reference that opens nothing, so it is safe to log and to hand on. After a
restart, the orders still open can be read back from us (Finding out where an order
stands).
Answering the price question ​
A card with price_check: 'handler' is sold at the price your price handler
names when an agent asks to buy. The handler answers in the same process, down
the same subscription:
agentify.on('quote', async (q) => {
const current = await currentPriceOf(q.merchant_item_id)
if (current === null) {
return q.unavailable()
}
return q.available(
{ amount: current.amount, currency: 'USD' },
current.checked_at,
)
})The last argument, as_of, is the moment the answer is true for; left out, it
is the moment of the answer itself, so name the moment your cache was filled
when you answer from one. An answer of none starts no purchase, and no answer
at all opens no order either: a price question reserves nothing, so set no
stock aside for it. What a silent price handler costs in each mode is on What
can go wrong.
The test buyer on step 6 pays no more than the price on the card, so it
refuses a price your handler names above that before it signs anything. Raise
its cap, the maxAmountPerPayment it sets, to try such a price.
It buys no parcel, whose price includes its shipping, since it sends no
address.
This step worked if the process starts, its first log line names the test channel and says the money there is not real, and your problem handler stays quiet. The first order reaches it on step 6.
Once the process has made its first poll, Products shows that your code
connected. Return to Products and save your seller name and wallet before
publishing: an address on Base, 0x followed by forty letters and
digits. In the test environment it only receives test USDC on Base Sepolia,
so it needs no money of its own. If you have no wallet yet,
Money says how to get one. Only the
dashboard can change this address; no API key can change it.
Continue when Products offers the publishing guide. You can change the seller name and wallet later in Settings.
4. Publish one card ​
const published = await agentify.catalog.publish({
merchant_item_id: 'access-monthly',
title: 'One month of access to the service',
description:
'What the buyer gets, what it is good for, and what is not included.',
price: '5.00 USD',
params: {
email: { type: 'string', required: true, title: 'Where to send it' },
},
result: { access_url: 'string' },
})
if (!published.ok) {
console.error(published.error.problems)
}merchant_item_id is your own key for the product: an order arrives carrying
it, and publishing again under it updates the card rather than adding a second
one, so publishing can live in a script you run as often as you like. params
is what the agent has to give at purchase, and result is what it receives,
which your handler will return exactly. The card names no mode, so it is
synchronous. The card reference has every field, the short and long
spellings used here, and which mode a product takes.
This step worked if published.ok is true and published.id holds the card's
catalogue identifier, which the test purchase on step 6 buys by. If ok is
false, error.problems lists everything that stops the card being published
in one answer, each finding with a code, words a person can act on and the
path to the field where there is one. Four findings are about you rather than
the card: no_seller_name, no_payout_wallet, no_seller_site on an
asynchronous or a parcel's card, and no_operator_approval on the live
channel (who checks a card).
Fixing what they name, not sending the card again, changes the answer.
A published card is in the catalogue only while a process of yours is polling, which step 3 starts. Until its first poll, and whenever none has polled for ten minutes, the catalogue leaves your cards out and every purchase of them is refused; they come back at the next poll (What can go wrong).
A call that fails for another reason, such as a key we do not accept or an
address that does not answer, throws an AgentifyError with a code, such as
not_authorised, a retryable flag and the route it happened on.
5. Check the card offline ​
npm exec --offline --package=@nuanu-ai/agentify -- agentify verify card.jsonThe check reads a card file the way we read it at publication and reports what
the contract can see from the card alone, without a key, an address or a
running handler. Run it from the project where you installed the package, so
npm uses that installation and never an unrelated package of the same short
name, and name the card files: called bare, it refuses and says why. The same
check is exported as checkCard, for cards assembled in code.
It answers 0 when every card it was given is complete as far as the contract
can tell, 1 when a card has findings, and 2 when it was called with
something it cannot work from, so a build script can stop on anything but 0.
What it cannot see is your delivery: a purchase parameter your delivery needs
and the card does not name goes through unremarked, and whether your handler
holds against a repeat is proved by the purchase on the next step.
6. Make your first test purchase ​
You make the first purchase yourself, buying your own card in the test environment
with an ordinary x402 client, the payment protocol agents pay with. Keep the
process from step 3 running while you buy: without it the card is not in the
catalogue, and a purchase is refused with seller_not_taking_orders.
In the directory with the .env from step 2, install the x402 client and the
library that makes and uses the buyer's wallet key:
npm install @x402/fetch@2.23.0 @x402/evm@2.23.0 viem@2.47.0Add the catalogue identifier step 4 returned to .env as a line of its own,
ITEM_ID=item_.... Then save the program below as buyer.mts beside .env
and run it with node --env-file=.env buyer.mts. It is TypeScript whose types
Node 24 simply erases, so it runs as it is.
import { setTimeout as sleep } from 'node:timers/promises'
import { registerExactEvmScheme } from '@x402/evm/exact/client'
import { wrapFetchWithPayment, x402Client } from '@x402/fetch'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
type Card = { id: string; purchase_url: string; price: { amount: string } }
type Order = { status?: string; status_url: string }
const { AGENTIFY_URL, ITEM_ID, BUYER_PRIVATE_KEY } = process.env
if (!AGENTIFY_URL || !ITEM_ID) throw new Error('.env needs AGENTIFY_URL and ITEM_ID.')
if (!BUYER_PRIVATE_KEY) {
const key = generatePrivateKey()
console.log(`Add this line to .env:\n\nBUYER_PRIVATE_KEY=${key}\n`)
console.log(`Then get test USDC on Base Sepolia for ${privateKeyToAccount(key).address}`)
console.log('at https://faucet.circle.com and run this program again.')
} else {
const catalog = await fetch(new URL('/x402/catalog', AGENTIFY_URL))
const { items } = (await catalog.json()) as { items: Card[] }
const card = items.find((item) => item.id === ITEM_ID)
if (!card)
throw new Error(`${ITEM_ID} is not in the catalogue. Is the process from step 3 running?`)
const client = new x402Client()
const signer = privateKeyToAccount(BUYER_PRIVATE_KEY as `0x${string}`)
registerExactEvmScheme(client, { signer, networks: ['eip155:84532'] })
// The x402 client refuses to pay more than 1 USD unless it is given a limit.
client.setSpendControls({ maxAmountPerPayment: `$${card.price.amount}` })
const answer = await wrapFetchWithPayment(fetch, client)(card.purchase_url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ params: { email: 'you@example.com' } }),
})
let order = (await answer.json()) as Order
for (let reads = 0; order.status === 'in_progress' && reads < 12; reads += 1) {
await sleep(5_000)
order = (await (await fetch(order.status_url)).json()) as Order
}
console.log(JSON.stringify(order, null, 2))
}The first run makes the buyer a wallet of its own and stops without buying.
It prints a BUYER_PRIVATE_KEY= line to add to .env and the address of that
wallet. Open Circle's faucet, choose USDC and Base
Sepolia, and paste the address: the faucet sends test USDC to it. The wallet
needs no ETH, because the payment is an authorisation the buyer signs and the
facilitator executes. Use this key for test purchases and nothing else.
The next run buys the card. The program finds it in the catalogue, posts to
its purchase address, signs the payment request that comes back and prints the
order. The parameters in the program are the email the card from step 4 asks
for; change them to what your card asks, or to {} for a card that asks for
none. The program sends no address, so it cannot buy a parcel. It signs only
on Base Sepolia and for no more than the price on the card. The x402 client
refuses any payment above 1 USD unless it is given a limit, and the program
sets that limit to the card's price, so a price check that asks more is
refused before anything is signed. While the order reads in_progress, the
program reads it again every five seconds for a minute, and then prints the
order as it stands; an order still in progress can be read later at the
status_url it printed.
If the answer to the purchase is lost, for example because the connection broke, look for the order on the Orders screen of your dashboard. Running the program again does not find that order: it buys the card a second time and pays its price in test USDC again. How an agent gets back an answer it lost is on Telling a repeat apart.
It all came together if the order reached your handler, the buyer received the
goods — delivered here, with the access_url your handler returned — and the
purchase left a receipt on the Receipts screen of your dashboard. The order
carries test: true, because the test environment settles on a test chain.
7. Go live ​
The live channel is a separate account at https://agentify.ad, signed in to
the same way, with a wallet that receives real USDC on Base and a key that
starts with csk_live_. Nothing is copied from the test environment: your code
publishes its cards again with the live key and points the client at the live
address. Live publication also waits for an Agentify operator to approve your
merchant. After your code connects, enter your seller name and press
Ask for live sales on Products. You can ask without a wallet, then add
one while you wait. The request and any decision note remain on Products.
A declined request sends an email with the note; correct what it asks and
press Ask again. Until approval, publishing lists
no_operator_approval, and nothing of yours sells live. The
operator can withdraw that approval later; publishing and new live sales are
then refused the same way, while the orders you already took are still yours to
deliver. What changes between the two channels is in one table on Connecting
to Agentify.
What is not settled yet ​
- How to take orders outside Node. The subscription's wire protocol is not documented for external consumers; the ready-made tools are for Node only.
- The other ways of receiving orders, which are designed and not open: a request to an address of yours, and a cursor that pulls orders in batches.
- When we start calling a price hook, an https address of yours asked instead of your price handler, and what your side will check its requests against to know that they came from us.
- A check that sends one order twice and watches for a second delivery, so that holding against repeats could be proved without a purchase.
- Whether an external discovery catalogue lists a product whose sale settled on a test chain. Publication puts the card in Agentify's own catalogue while a process of yours polls, and no other listing follows from it or from a completed purchase.