For the engineer

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.

One purchase, from the catalogue to the goodsThe agent reads the catalogue and asks to buy a card. If the card has a price check, which a parcel's card always has, Agentify asks your process the price and waits ten seconds; an answer of none refuses the purchase on any card, and silence refuses it on a card charged at purchase while a synchronous card sells at its own price. Agentify answers 402 Payment Required with the price and your payout contract as the payee, and the price holds three minutes. The agent repeats the call with a signed payment and the facilitator verifies it. In the synchronous mode the order goes to your process, which answers each hand-over within three seconds and has ten seconds in all from the payment check to send back the goods; the facilitator then executes the charge into your payout contract and the agent receives the goods. A refusal or silence instead charges nothing, and a failed charge leaves the order delivered_unpaid. In the asynchronous and parcel modes the charge is executed first, the agent receives an order it reads again later, your process answers each hand-over within three seconds, accepting the order or delivering at once, and delivers later with a separate call by the deadline on your card; a parcel's delivery is its shipment. A refusal or a missed deadline instead leaves a paid order unfulfilled.FacilitatorYour processAgentifyBuyer's agentFacilitatorYour processAgentifyBuyer's agentnone: refused on any cardsilence: refused on asyncor ship, while a sync cardsells at its own priceopt[a price check (a parcelalways has one)]the price holds 3 mina refusal or silence:nothing is chargeda failed charge:delivered_unpaida refusal or a misseddeadline: unfulfilledalt[sync][async or ship]reads the catalogue,asks to buy one cardthe price questiona price, or none (10 s)402: the price, payableto your payout contractthe same call,with a signed paymentverify the paymentthe orderthe goods (3 s perhand-over, 10 s in allfrom the payment check)charge, into yourpayout contractthe goodscharge, into yourpayout contractthe order, no goods yetthe orderaccepted, or the goods(3 s per hand-over)deliver, or a parcel'sshipment, by the deadlinereads the order againthe goods, or shipped

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 ​

sh
npm install @nuanu-ai/agentify

It 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.

ts
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:

sh
AGENTIFY_API_KEY=csk_test_...
AGENTIFY_URL=https://test.agentify.ad

Run 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.

ts
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.

ts
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:

ts
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:

ts
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 ​

ts
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 ​

sh
npm exec --offline --package=@nuanu-ai/agentify -- agentify verify card.json

The 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:

sh
npm install @x402/fetch@2.23.0 @x402/evm@2.23.0 viem@2.47.0

Add 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.

ts
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.