For developers
Developer docs
Check a payee, relay a transfer, and get told when it settles. The partner API, event webhooks, a TypeScript SDK and an MCP server for AI agents, on Arc mainnet (chain 5042).
Overview
Notol is stablecoin escrow on Arc (USDC, 6 decimals). A transfer is locked on chain. It releases only when the intended recipient proves control of the destination, otherwise it refunds to the sender.
For an integrator, the api gives you:
- transfer status and history for your users’ wallets;
- a payee check: send direct, send through escrow, or refuse;
- a confirm link for you to hand to the recipient over a channel you already trust;
- event webhooks when a transfer is locked, released or refunded.
What a key can and cannot do
An API key identifies and meters you. It never moves money.
- Every lock is signed by the sender’s own wallet.
- Every release needs the recipient’s proof: the destination and the contact key (confirm), the claim key and the PIN co-signature (claim), or the contact key and a verified probe.
- The api relays transactions. It never decides where money goes.
A leaked key costs quota, never funds.
The full route reference is the OpenAPI spec. The base URL is https://api.notol.app.
Get an API key
Keys are self-serve. Open app.notol.app/developers, connect a wallet and sign in with a wallet signature. Your wallet is your account.
- Sign-in signs a read pass that is valid for at most 10 minutes. Creating or revoking a key, and creating or deleting a webhook, each need a fresh signature. The signature moves no money.
- The full key is shown once, when you create it, and is never stored. Copy it then.
- Each wallet can hold 3 active keys. New keys are on the Free plan.
- The page also shows your usage and bill for a month, and lets you create and delete event webhooks and read their delivery log.
- Revoke a key there at any time. Revoking a key disables its webhooks at once.
- If you lose the wallet, the operator can move your keys to a new wallet after checking who you are outside the app.
Format: ntl_ followed by 43 base64url characters. Use a key on your server only. Never put one in a browser, a mobile app or a client bundle.
Base rules and auth
- JSON only. Request bodies are capped at 32 KB.
- Amounts are base-unit decimal strings.
"1000000"is 1 USDC. Never send numbers. - Addresses are
0x+ 40 hex. Ids and tx hashes are0x+ 64 hex. - Errors are
{ "error": "<CODE>", "message": "...", "attemptsLeft"?: n }. - The api works without a key. Anonymous calls are limited per IP.
Send the key as a header:
Authorization: Bearer ntl_EXAMPLE_replace_with_your_key- No header means an anonymous request.
- A header that is not a valid, unrevoked key is
401 BAD_API_KEY. The api never falls back to anonymous for a bad key. - Keep keys out of logs and source control. If one leaks, revoke it in the portal.
Rate limits and plans
- With a key, the limit is per key and per route. The default is 120 requests per minute. Free-plan keys made in the portal also share that limit per IP address. Paid-plan keys and keys issued by the operator have their own bucket.
- Creating portal keys: at most 3 active keys per wallet, and at most 10 new keys per IP address per rolling day (revoked keys count).
- Without a key, the limits are per IP: 10 per minute per write route, and 60 per minute for the chain-read fallback of
GET /v1/transfers/{id}. - Over the limit:
429 RATE_LIMITED. Wait and retry. Counters are in memory and reset when the api restarts.
Plans and billing
Each key is on a plan. Prices are starting points and may change. Plan changes are made by the operator, not in the portal.
| Plan | Price | Settled transfers / month | Payee checks / month |
|---|---|---|---|
| Free | 0 | 1,000 | 10,000, then 429 QUOTA_EXCEEDED until the next UTC month |
| Growth | 0.10 USDC per settled transfer over 1,000 | unlimited | 100,000 included, then 0.001 USDC each |
| Enterprise | Contract | Contract | Contract |
- A settled transfer is one your key touched with a successful
POST /v1/transfers/{id}/…(contact, claim, confirm, reject, report, release, refund) and that was then released. It counts once per key, however many calls you made. A refunded transfer is free. - It is billed in the UTC month of its release.
- A payee check is a
GET /v1/payees/checkwith your key that returned200. Checks without a key are free and limited per IP only. - Billing never refuses a transfer route. Going over the Free plan’s transfers is allowed; the operator asks you to move to Growth.
- Invoices are monthly, in USDC.
Idempotency-Key
Send Idempotency-Key on a POST write so a retry cannot repeat it. This matters most for POST /v1/transfers/{id}/contact, which notifies on every call where a channel is on. On api.notol.app the sealed webhook channel is the only one that can be on, once enabled; email, plaintext webhook and Telegram stay off.
- 1 to 64 characters of
A-Z a-z 0-9 _ -. - It needs an API key. Without one:
401 API_KEY_REQUIRED. The scope is per key. - The first request stores its final status and body. A replay with the same route and body returns that response, with the header
Idempotent-Replayed: true. - The same key with another route or body:
422 IDEMPOTENCY_MISMATCH. - The same key while the first request is still running:
409 IDEMPOTENCY_IN_FLIGHT. Wait and retry. 5xxand429answers are not stored, so the retry runs again.- Records are kept 24 hours.
- Every other answer is stored, including state errors that may change later, such as
NOT_EXPIREDorPROBE_NOT_VERIFIED. Retrying with the same key returns the same error for 24 hours. To try again after the state changed, use a newIdempotency-Key. - A webhook signing secret is never stored with the response. A replay of
POST /v1/webhooksre-derives it, so you get the same secret back. - No route returns a link secret, so a stored response never holds one.
Payee check
GET https://api.notol.app/v1/payees/check?sender=0x…&recipient=0x…Returns:
{ "decision": "escrow", "reason": "new_payee", "verified": null, "lookalikeOf": null }| decision | reason | Meaning |
|---|---|---|
| direct | verified_pair | This sender already verified this recipient. A plain transfer is allowed. |
| escrow | new_payee | Not seen before. Use escrow. |
| blocked | lookalike | The recipient imitates one of the sender’s verified payees. lookalikeOf names it. |
| blocked | self | Sender and recipient are the same address. |
| blocked | reserved | The recipient is the zero, escrow, verifier, registry or token address. |
verified is a chain read of the registry, set only when the pair is verified.
direct is a hint, not an authority. Before you send direct, confirm it on chain yourself: read PayeeRegistry.isVerified(sender, recipient). If the call fails or says false, use escrow. The api never decides where money goes.
If the api cannot read the chain or its mirror, it answers 500 INTERNAL, never escrow. Treat an error as unknown and do not send until a check succeeds.
The route uses the read limit and needs no key. With a key, a successful check counts toward your plan.
Sending when the user holds the wallet
The user’s wallet signs and locks. The api never holds the key and never locks for you.
- Call
GET /v1/payees/check. Stop onblocked. - The sender’s client makes a 16-byte link secret, derives the contact key from it, and locks the transfer on chain with the contact key’s address.
The sender’s client signs the
contactMessageand callsPOST /v1/transfers/{id}/contactwith the channel, the recipient’s handle, the link secret and the signature. Use anIdempotency-Key.- On api.notol.app the server delivers no plaintext links: email, plaintext webhook and Telegram are off. The sender delivers the confirm link themselves over a channel they already trust for this recipient. The secret travels in the URL fragment and is never stored.
- Sealed webhook (once enabled): the payee publishes an X25519 public key at its https URL (
GETanswers{ "type": "notol.contact-key", "v": 1, "keys": [...] }). The sender seals the link secret to that key and sendschannel: webhook_sealedwith aboxinstead of the link secret. The api stores and forwards only the box, POSTsconfirm.sealedto the payee, and cannot read the secret. A hacked api can delay or drop delivery, not read it. Take the URL and the key from your own vendor records, never from the invoice that carries the address. Without the channel on, the call returns409 SEALED_CONTACT_DISABLED.
- Watch the transfer with
GET /v1/transfers/{id}or an event webhook. - After expiry, a transfer that was never confirmed refunds with
POST /v1/transfers/{id}/refund. The operator’s sweep also does this for amounts at or above the sweep minimum.
The secret must derive the on-chain contact key, else 400 BAD_LINK_SECRET. A transfer below the notify minimum is refused with 409 INVALID_REQUEST. On api.notol.app email, plaintext webhook and Telegram delivery are off: asking for them returns 409 EMAIL_DISABLED, 409 WEBHOOK_CHANNEL_DISABLED or 409 TELEGRAM_DISABLED.
For a server-side agent that holds its own key, use the SDK instead of calling these routes by hand.
Event webhooks
The api calls your server when a transfer changes. Endpoints belong to your API key. You can create them in the portal or over the api.
Create
POST /v1/webhooks
Authorization: Bearer ntl_EXAMPLE_replace_with_your_key
{
"url": "https://example.com/hooks/notol",
"events": ["transfer.locked", "transfer.released", "transfer.refunded"],
"senders": ["0x…"],
"recipients": []
}Response 201: the endpoint plus secret. The secret is shown once. To rotate it, delete the endpoint and create a new one.
urlmust behttpson port 443. The api resolves the host and refuses loopback, private, link-local, CGNAT, multicast and metadata addresses. It follows no redirects and gives up after 5 seconds.eventsis any oftransfer.locked,transfer.released,transfer.refunded.- Filters: give at least one address in
sendersorrecipients, at most 1000 addresses in total. The data is public on chain, so you can filter by any address. - At most 10 active endpoints per key.
- A Free-plan key made in the portal cannot create event webhooks:
403 PLAN_REQUIRED. Ask the operator to move the key to a paid plan. Keys issued by the operator and paid keys can create them. GET /v1/webhookslists your endpoints (no secrets;urlmasked toscheme://host/…, the full URL is shown once, at create).DELETE /v1/webhooks/{id}removes one.- If the feature is off on the server:
404 WEBHOOKS_DISABLED.
Payload
{
"id": "evt_…",
"type": "transfer.released",
"createdAt": 1767225600,
"data": { "id": "0x…", "status": "Released", "...": "TransferView" }
}data is the same TransferView that the public GET /v1/transfers/{id} returns: the chain fields plus the masked contact (b***@example.com) and the pinSet / claimLocked flags. Both flags are boolean | null: null means the api could not reach the co-signer service, so the value is unknown, not false. It is the transfer as it is at send time, not at event time. A late delivery shows the current state. Use type for what happened and data.status for where it stands now.
Verify the signature
Each delivery carries:
Notol-Signature: t=<unix seconds>,v1=<hex>v1 is HMAC-SHA256 with your endpoint secret over the string "<t>.<raw body>". Use the raw bytes you received, before any JSON parse. Refuse a t more than 5 minutes from your clock.
The TypeScript SDK ships a helper that checks the timestamp tolerance and compares in constant time. In another language, implement the check above:
import { verifyWebhook } from '@notol/agent';
// raw body string, header value, endpoint secret
const ok = await verifyWebhook(secret, req.headers['notol-signature'], rawBody);
if (!ok) return res.writeHead(401).end();Handle deliveries
- Answer with a
2xxquickly. Do the work after you reply. - The same event can arrive more than once. Dedupe by the event
id. It is stable per event. - Order is not guaranteed.
- A non-
2xxanswer or a timeout is a failure. Retries run at 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours after the previous attempt, 8 attempts in total. After that the delivery is marked failed. - When a delivery fails its 8th attempt, the endpoint is disabled (
disabled: truein the listing) and its other pending deliveries fail too. Fix the receiver, delete the endpoint and create a new one. Disabled endpoints are deleted after 7 days. - Revoking your API key disables its endpoints at once.
- Deliveries for operator-issued and paid keys go first, then accounts are served round-robin. Each endpoint gets at most 5 deliveries per 3-second round, and a slow receiver cannot block other partners’ deliveries.
Errors
The most common codes. A route may also return 400, 429 and 500 on its own. Handle and PIN errors are in the OpenAPI spec.
| Status | Code | Meaning |
|---|---|---|
| 400 | INVALID_REQUEST | Bad body, bad path or query value, or a body over 32 KB. |
| 400 | BAD_LINK_SECRET | The link secret does not derive the on-chain contact key. |
| 401 | BAD_SIGNATURE | A signature is missing, wrong or from the wrong signer. |
| 401 | BAD_API_KEY | Authorization is present but the key is malformed, unknown, revoked, or keys are off. |
| 401 | API_KEY_REQUIRED | The route (or Idempotency-Key) needs an API key. |
| 403 | PLAN_REQUIRED | A Free-plan portal key tried to create an event webhook. Move the key to a paid plan. |
| 404 | NOT_FOUND | No such transfer or handle. |
| 404 | WEBHOOKS_DISABLED | Event webhooks are off on this server. |
| 409 | NOT_PENDING | The transfer is already settled. |
| 409 | EXPIRED | The transfer has expired. |
| 409 | NOT_EXPIRED | A refund was asked before expiry. |
| 409 | EMAIL_DISABLED | Email delivery is off on this server (the SDK also throws it before locking). |
| 409 | WEBHOOK_CHANNEL_DISABLED | Webhook link delivery is off on this server (the SDK throws it before locking). |
| 409 | TELEGRAM_DISABLED | Telegram delivery is not configured. |
| 409 | IDEMPOTENCY_IN_FLIGHT | A request with this Idempotency-Key is still running. |
| 422 | SIMULATION_FAILED | The contract call would revert. The message names the decoded revert. |
| 422 | IDEMPOTENCY_MISMATCH | The Idempotency-Key was used with another route or body. |
| 429 | RATE_LIMITED | Over the rate limit. |
| 429 | QUOTA_EXCEEDED | A Free plan key used its monthly payee checks. Only /payees/check returns it. |
| 500 | INTERNAL | Server error, including a failed chain or mirror read in /payees/check. Retry with the same Idempotency-Key. |
| 503 | RELAYER_UNAVAILABLE | This instance is not the relayer leader or is shutting down. Retry. |
| 503 | RELAYER_DRY_RUN | The relayer is in dry-run mode: confirm, claim, release and refund relays are not sent. Retry later. |
500 and 503 answers are not stored by Idempotency-Key, so a retry runs again.
SDK (TypeScript)
Notol for AI agents and servers that hold their own key: send USDC to an address and confirm a received link over the api, without web pages. Address mode only. Node 20 or later, ESM, on npm as @notol/agent:
npm install @notol/agent viemimport { createAgent } from '@notol/agent';
import { privateKeyToAccount } from 'viem/accounts';
const agent = createAgent({
account: privateKeyToAccount(process.env.AGENT_KEY as `0x${string}`),
chainId: 5042, // Arc mainnet
apiUrl: 'https://api.notol.app',
webOrigin: 'https://app.notol.app',
});
// Sender. Amounts are USDC base units (6 decimals) as bigint.
const sent = await agent.sendToAddress({ to: '0x…', amount: 1_000_000n, notify: 'share' });
// Then deliver sent.confirmLink yourself, by a channel you already trust for this recipient (api.notol.app sends no email).
// Recipient (its own agent, holding the destination key).
await agent.confirmFromLink(link, { displayName: 'Acme bot' });
await agent.transferStatus(sent.transferId); // TransferView: Pending | Released | Refunded
await agent.cancel(sent.transferId); // sender only, while Pending: Escrow.cancel, back to youPolicy and send
const agent = createAgent({ …, policy: { maxPerTransfer: 50_000_000n, maxPerDay: 200_000_000n, escrowAbove: 10_000_000n } });
const r = await agent.send({ to: '0x…', amount: 5_000_000n, notify: 'share' });
if (r.kind === 'direct') r.txHash;
else deliverToPayee(r.confirmLink); // escrow: a SendResult, as sendToAddress. You hand the link to the payee (api.notol.app sends none).sendneeds a policy (POLICY_REQUIRED). With a policy,sendToAddressobeys its limits too; it always escrows.- A direct ERC-20 transfer happens only when the api says
directANDPayeeRegistry.isVerified(you, to)reads true on your own RPC ANDamount <= escrowAbove.escrowAboveunset means never direct. A direct send has no escrow and no refund. - The policy is a soft guard: it lives in the same process as the key.
- On api.notol.app the server delivers no confirm link: email, webhook and Telegram delivery are all off. Use
notify: 'share'and handconfirmLinkto the payee yourself. The SDK refusesnotify: { email }andnotify: { webhook }before anything locks whenGET /v1/infosays the channel is off (EMAIL_DISABLED,WEBHOOK_CHANNEL_DISABLED). If that read fails, it throwsNETWORK. - On a server where the webhook channel is on, take the
notifyURL from your own vendor records, never from the invoice or message that carries the address. Whoever controls the webhook gets the link and can confirm. - One agent instance per account. Money calls run one at a time per instance; two processes with the same key can pick the same nonce.
Errors
Refusals throw AgentError with a code, before anything is locked or signed. Api errors keep the api code, such as NOT_PENDING or RATE_LIMITED. If a lock may or may not have landed (LOCK_UNCONFIRMED), do not send again; resume, which re-broadcasts the same signed tx and so can never lock twice:
try {
await agent.sendToAddress(req);
} catch (e) {
if (e instanceof AgentError && e.code === 'LOCK_UNCONFIRMED') await agent.resumeSend(e);
else throw e;
}The package README lists every code and the receiving-by-webhook handler (createConfirmHandler).
MCP server for AI agents
A local stdio MCP server that lets an AI agent (Claude Desktop, Claude Code, any MCP client) pay through Notol with its own key. It wraps the SDK; there is no custody and no hosting. The key never leaves your machine, and nothing talks to Notol except the api and your chain RPC.
Check only (no key)
With NOTOL_AGENT_KEY unset the server starts with two tools, notol_check_payee and notol_transfer_status. It holds no key and moves no funds. Only NOTOL_API_URL is required; NOTOL_SENDER is the public address your agent pays from. 0.1.2 is the first version with this mode. The check flags a lookalike of a payee the sender verified in Notol’s on-chain registry (address poisoning), the sender itself, and reserved addresses. It is a hint (a send re-checks on chain), it knows only payees verified in Notol’s registry on Arc, and an escrow answer for a new payee is not a verdict that the address is safe.
{
"mcpServers": {
"notol": {
"command": "npx",
"args": ["-y", "@notol/mcp@0.1.3"],
"env": {
"NOTOL_API_URL": "https://api.notol.app",
"NOTOL_SENDER": "0xYOUR_PAYING_WALLET_ADDRESS"
}
}
}
}Give your agent a wallet
One command creates the agent’s key file. --owner is your own wallet, the one address the agent can send funds back to; options are --chain 5042 (default) and --file path. The file defaults to ~/.notol/agent-<chainId>.json (%USERPROFILE%\.notol\ on Windows). The command prints the agent address, the owner, the file path and an mcpServers snippet that uses NOTOL_AGENT_KEY_FILE. It never prints the key and refuses to overwrite an existing file. 0.1.2 is the first version with this.
npx @notol/mcp@0.1.3 init --owner 0xYOUR_WALLETA new wallet holds 0. It can receive plain USDC transfers at its address right away and can pay only once funded; gas on Arc is paid in USDC. Receiving Notol escrow payments through MCP is not in this version. The key file is the only copy of the key: back it up, or keep only what you can lose in it. On POSIX the file is mode 0600 and the server refuses a key file others can read; on Windows there is no mode check and the file inherits your user profile’s access rules.
Run it
On npm as @notol/mcp; your MCP client starts it with npx (Node 20 or later). To pay as well, give it a key. For Claude Desktop (claude_desktop_config.json) or a project .mcp.json for Claude Code, add the block below. Pin the version, so an update reaches the process that holds your key only when you choose it. On Windows, if the client cannot start npx, use npx.cmd. The key below is a placeholder:
{
"mcpServers": {
"notol": {
"command": "npx",
"args": ["-y", "@notol/mcp@0.1.3"],
"env": {
"NOTOL_AGENT_KEY": "0xYOUR_AGENT_PRIVATE_KEY_64_HEX_CHARS_REPLACE_ME_NOT_REAL_000000000000",
"NOTOL_API_URL": "https://api.notol.app",
"NOTOL_WEB_ORIGIN": "https://app.notol.app",
"NOTOL_CHAIN_ID": "5042",
"NOTOL_MAX_PER_TRANSFER": "50",
"NOTOL_MAX_PER_DAY": "200",
"NOTOL_VERIFIED_ONLY": "0"
}
}
}
}Environment
A missing or malformed value stops the server at start, with an error that names the variable and never prints a value.
| Variable | Meaning |
|---|---|
| NOTOL_AGENT_KEY | Required to pay, or NOTOL_AGENT_KEY_FILE; neither set = check-only mode. The agent’s private key, 0x + 64 hex. Give it only the USDC (and gas) you are willing to lose. |
| NOTOL_AGENT_KEY_FILE | Alternative to NOTOL_AGENT_KEY: path to the key file made by init. Both set = the server refuses to start. A missing, unreadable or malformed file, a file for another chain, an address that does not match the key, or an invalid owner also stops it (never a fallback to check-only). NOTOL_CHAIN_ID must match the file. |
| NOTOL_API_URL | Required. Notol api base URL. |
| NOTOL_WEB_ORIGIN | Required with a key. Web origin the confirm link points at. |
| NOTOL_CHAIN_ID | Required with a key. Integer. |
| NOTOL_MAX_PER_TRANSFER | Required with a key. Policy cap per transfer, decimal USDC string. |
| NOTOL_MAX_PER_DAY | Optional. Cap on the last 24 h of escrow locks plus this process’s sends. |
| NOTOL_ESCROW_ABOVE | Optional. A verified payee is paid directly up to and including this amount; above it, escrow. Unset = never direct. Needs NOTOL_MAX_PER_DAY (direct sends have no refund), or the server refuses to start. |
| NOTOL_VERIFIED_ONLY | Optional. 1 refuses any payee not verified on chain. Anything but 1 is off. |
| NOTOL_RPC_URL | Optional. Default: the chain’s public RPC. |
| NOTOL_API_KEY | Optional. ntl_… partner key; meters and rate-limits, never moves money. |
| NOTOL_SENDER | Optional, check-only mode only. The public address the agent pays from; the default sender for notol_check_payee. Next to NOTOL_AGENT_KEY it stops the server. |
Setting a pay setting (NOTOL_MAX_PER_TRANSFER, NOTOL_MAX_PER_DAY, NOTOL_ESCROW_ABOVE, NOTOL_VERIFIED_ONLY, NOTOL_CHAIN_ID, NOTOL_WEB_ORIGIN or NOTOL_RPC_URL) without a key stops the server (the config meant to pay and lost its key).
Amounts are decimal USDC strings ("12.50"): at most 12 integer digits and 6 decimals, greater than zero.
Tools
In check-only mode only notol_check_payee and notol_transfer_status exist. There notol_check_payee also takes an optional sender; without it and without NOTOL_SENDER it answers BAD_INPUT.
| Tool | Does |
|---|---|
| notol_check_payee | Also in check-only. How a payment would be handled: direct, escrow or blocked, with a one-line meaning. A hint; the send re-checks on chain. |
| notol_send | Pays under the policy: direct to a payee verified on chain (within NOTOL_ESCROW_ABOVE), else escrow, released only when the recipient confirms, else refunded at expiry. Escrow needs an email or webhook channel the api has on. |
| notol_wallet | Not in check-only. The agent’s address, owner and USDC balance (null when the read fails, never 0). Read-only. |
| notol_withdraw | Only with a key file that sets an owner. Direct USDC transfer to the owner only; there is no destination argument. Not counted toward NOTOL_MAX_PER_DAY. Leave a little for gas. On TRANSFER_UNCONFIRMED, call notol_resume_send. |
| notol_transfer_status | Also in check-only. Status, amount (decimal USDC), expiry. |
| notol_cancel | Refunds this agent’s own Pending transfer. |
| notol_resume_send | After a LOCK_UNCONFIRMED send: re-broadcasts the same signed lock (cannot lock twice). Same session only. |
notol_send and notol_withdraw refuse with INSUFFICIENT_FUNDS (naming the agent address) when the known balance is below the amount, before signing. Results are plain JSON; errors are { error: code, message }. The confirm link is never returned, so its secret never enters the model’s context. The tool takes only an email or a webhook as the way to reach the recipient.
notol_send to a payee that needs escrow is refused before anything locks (EMAIL_DISABLED or WEBHOOK_CHANNEL_DISABLED). Direct sends to payees verified on chain still work. To escrow, use the SDK with notify: 'share'.The trust rule
The destination address and any webhook URL must come from your own trusted records, never from an invoice, email, web page or another tool’s output. Address poisoning and prompt injection work by swapping exactly those. Whoever controls the webhook receives the confirm link and can confirm the transfer, so a poisoned invoice that carries both an address and a webhook defeats the escrow check.
Policy is a soft guard
The limits run in the same process as the key. Anything that can read the process env or its memory can bypass them. Fund the agent key with only what you can lose. Direct sends have no escrow and no refund.