Skip to content

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 are 0x + 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.

PlanPriceSettled transfers / monthPayee checks / month
Free01,00010,000, then 429 QUOTA_EXCEEDED until the next UTC month
Growth0.10 USDC per settled transfer over 1,000unlimited100,000 included, then 0.001 USDC each
EnterpriseContractContractContract
  • 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/check with your key that returned 200. 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.
  • 5xx and 429 answers 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_EXPIRED or PROBE_NOT_VERIFIED. Retrying with the same key returns the same error for 24 hours. To try again after the state changed, use a new Idempotency-Key.
  • A webhook signing secret is never stored with the response. A replay of POST /v1/webhooks re-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 }
decisionreasonMeaning
directverified_pairThis sender already verified this recipient. A plain transfer is allowed.
escrownew_payeeNot seen before. Use escrow.
blockedlookalikeThe recipient imitates one of the sender’s verified payees. lookalikeOf names it.
blockedselfSender and recipient are the same address.
blockedreservedThe 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.

  1. Call GET /v1/payees/check. Stop on blocked.
  2. 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.
  3. The sender’s client signs the contactMessage and calls POST /v1/transfers/{id}/contact with the channel, the recipient’s handle, the link secret and the signature. Use an Idempotency-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 (GET answers { "type": "notol.contact-key", "v": 1, "keys": [...] }). The sender seals the link secret to that key and sends channel: webhook_sealed with a box instead of the link secret. The api stores and forwards only the box, POSTs confirm.sealed to 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 returns 409 SEALED_CONTACT_DISABLED.
  4. Watch the transfer with GET /v1/transfers/{id} or an event webhook.
  5. 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.

  • url must be https on 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.
  • events is any of transfer.locked, transfer.released, transfer.refunded.
  • Filters: give at least one address in senders or recipients, 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/webhooks lists your endpoints (no secrets; url masked to scheme://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 2xx quickly. 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-2xx answer 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: true in 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.

StatusCodeMeaning
400INVALID_REQUESTBad body, bad path or query value, or a body over 32 KB.
400BAD_LINK_SECRETThe link secret does not derive the on-chain contact key.
401BAD_SIGNATUREA signature is missing, wrong or from the wrong signer.
401BAD_API_KEYAuthorization is present but the key is malformed, unknown, revoked, or keys are off.
401API_KEY_REQUIREDThe route (or Idempotency-Key) needs an API key.
403PLAN_REQUIREDA Free-plan portal key tried to create an event webhook. Move the key to a paid plan.
404NOT_FOUNDNo such transfer or handle.
404WEBHOOKS_DISABLEDEvent webhooks are off on this server.
409NOT_PENDINGThe transfer is already settled.
409EXPIREDThe transfer has expired.
409NOT_EXPIREDA refund was asked before expiry.
409EMAIL_DISABLEDEmail delivery is off on this server (the SDK also throws it before locking).
409WEBHOOK_CHANNEL_DISABLEDWebhook link delivery is off on this server (the SDK throws it before locking).
409TELEGRAM_DISABLEDTelegram delivery is not configured.
409IDEMPOTENCY_IN_FLIGHTA request with this Idempotency-Key is still running.
422SIMULATION_FAILEDThe contract call would revert. The message names the decoded revert.
422IDEMPOTENCY_MISMATCHThe Idempotency-Key was used with another route or body.
429RATE_LIMITEDOver the rate limit.
429QUOTA_EXCEEDEDA Free plan key used its monthly payee checks. Only /payees/check returns it.
500INTERNALServer error, including a failed chain or mirror read in /payees/check. Retry with the same Idempotency-Key.
503RELAYER_UNAVAILABLEThis instance is not the relayer leader or is shutting down. Retry.
503RELAYER_DRY_RUNThe 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 viem
import { 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 you

Policy 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).
  • send needs a policy (POLICY_REQUIRED). With a policy, sendToAddress obeys its limits too; it always escrows.
  • A direct ERC-20 transfer happens only when the api says direct AND PayeeRegistry.isVerified(you, to) reads true on your own RPC AND amount <= escrowAbove. escrowAbove unset 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 hand confirmLink to the payee yourself. The SDK refuses notify: { email } and notify: { webhook } before anything locks when GET /v1/info says the channel is off (EMAIL_DISABLED, WEBHOOK_CHANNEL_DISABLED). If that read fails, it throws NETWORK.
  • On a server where the webhook channel is on, take the notify URL 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_WALLET

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

VariableMeaning
NOTOL_AGENT_KEYRequired 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_FILEAlternative 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_URLRequired. Notol api base URL.
NOTOL_WEB_ORIGINRequired with a key. Web origin the confirm link points at.
NOTOL_CHAIN_IDRequired with a key. Integer.
NOTOL_MAX_PER_TRANSFERRequired with a key. Policy cap per transfer, decimal USDC string.
NOTOL_MAX_PER_DAYOptional. Cap on the last 24 h of escrow locks plus this process’s sends.
NOTOL_ESCROW_ABOVEOptional. 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_ONLYOptional. 1 refuses any payee not verified on chain. Anything but 1 is off.
NOTOL_RPC_URLOptional. Default: the chain’s public RPC.
NOTOL_API_KEYOptional. ntl_… partner key; meters and rate-limits, never moves money.
NOTOL_SENDEROptional, 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.

ToolDoes
notol_check_payeeAlso 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_sendPays 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_walletNot in check-only. The agent’s address, owner and USDC balance (null when the read fails, never 0). Read-only.
notol_withdrawOnly 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_statusAlso in check-only. Status, amount (decimal USDC), expiry.
notol_cancelRefunds this agent’s own Pending transfer.
notol_resume_sendAfter 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.

Escrow sends do not work against api.notol.app. Email, webhook and Telegram delivery are off there, so 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.