openapi: 3.1.0 info: title: Notol API version: 1.0.0 description: | Notol is stablecoin escrow on Arc (USDC, 6 decimals). Funds release only when the intended recipient proves control of the destination, else they refund to the sender. Contract: `packages/shared/src/api.ts` and `packages/shared/src/handles.ts`. This file is hand-written. A test (`apps/api/test/openapi.test.ts`) fails if a route registered in `apps/api/src` is missing here, or the reverse. Conventions: - JSON only. Request bodies are capped at 32 KB. - Amounts are base-unit decimal strings (6 decimals), never numbers. - Addresses are 0x + 40 hex. Ids and hashes are 0x + 64 hex. - Errors are `{ error, message, attemptsLeft?, handle? }`. **An API key never authorises moving money.** It identifies and meters a partner. Every lock is signed by the sender's 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. A leaked key costs quota, never funds. Guide for integrators: `docs/api/partners.md`. servers: - url: https://api.notol.app description: Arc mainnet (chain 5042). security: - {} - bearerAuth: [] tags: - name: transfers - name: signed actions - name: payees - name: handles - name: namespaces description: Custom-domain handle namespaces (D64). Dark unless `NAMESPACES_ENABLED=1`. - name: webhooks - name: portal description: Self-serve partner portal (D58), wallet-signed, for the web app's `/developers` page. - name: system paths: /health: get: tags: [system] summary: Liveness security: [] operationId: health responses: '200': description: The process is up. content: application/json: schema: type: object required: [ok] properties: ok: { type: boolean, const: true } /v1/info: get: tags: [system] summary: Which contact channels this api delivers description: >- No key needed. Uses the read rate limit. On `https://api.notol.app` (Arc mainnet) email, plaintext webhook-contact and Telegram delivery are off (D55), so the sender shares the confirm link themselves, or uses the sealed webhook channel (`webhookSealed`, D65) once it is on. The SDK reads this before it locks and refuses `notify: { email }` or `{ webhook }` when that channel is off. security: [] operationId: getInfo responses: '200': description: The chain and the contact channels that deliver. content: application/json: schema: { $ref: '#/components/schemas/ApiInfo' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/activity: get: tags: [system] summary: Public activity feed (settled transfers, no addresses) description: >- No key needed, any origin (CORS `*`). Uses the read rate limit. Settled transfers only, never pending ones. No address, transfer id, display name or tx hash. Amounts are rounded to 2 significant digits and times are floored to 15 minutes. The whole response changes only every 15 minutes: `asOf` is the start of the last full 15-minute slot at least 15 minutes ago, and only transfers settled before it count. Transfers under 1 USDC/EURC are left out of both the list and the totals (D62). security: [] operationId: getActivity responses: '200': description: The 20 most recent settled transfers before `asOf`, and all-time totals. content: application/json: schema: { $ref: '#/components/schemas/ActivityFeed' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/stats: get: tags: [system] summary: Public headline numbers (aggregate, no addresses) description: >- No key needed, any origin (CORS `*`). Uses the read rate limit. Same rules as `/v1/activity`: settled transfers only, the same 15-minute `asOf` cut-off, transfers under 1 USDC/EURC left out. Values are USDC only, base units rounded to 2 significant digits. Payee checks are self-reported by client kind (`client` on `/v1/payees/check`) and counted in whole UTC days before `checksAsOf`. A failed read is a 500, never zeros. security: [] operationId: getStats responses: '200': description: The aggregate numbers. content: application/json: schema: { $ref: '#/components/schemas/PublicStats' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/transfers/{id}: get: tags: [transfers] summary: Read one transfer description: | Mirror first, else a chain read. The contact is masked. The chain-read fallback is limited to 60 requests per minute per IP. operationId: getTransfer parameters: - $ref: '#/components/parameters/TransferId' responses: '200': description: The transfer. content: application/json: schema: { $ref: '#/components/schemas/TransferView' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadApiKey' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/transfers: get: tags: [transfers] summary: List a sender's transfers, or the transfers paid out to an address description: >- Pass exactly one of `sender` (any status, newest lock first) or `releasedTo` (Released only, newest release first; pending transfers to an address are never listed for it). At most 200. operationId: listTransfers parameters: - name: sender in: query required: false schema: { $ref: '#/components/schemas/Address' } - name: releasedTo in: query required: false schema: { $ref: '#/components/schemas/Address' } responses: '200': description: Transfers of this sender, or paid out to this address. content: application/json: schema: type: object required: [transfers] properties: transfers: type: array maxItems: 200 items: { $ref: '#/components/schemas/TransferView' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadApiKey' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/transfers/{id}/contact: post: tags: [signed actions] summary: Deliver the confirm link to the recipient's channel description: | Flows A and C. Needs a Pending ToAddress transfer or a Verified one. Signed by the sender with an EIP-191 `contactMessage`. `contactKeyFromSecret(linkSecret)` must equal the on-chain `contactKey`, else `BAD_LINK_SECRET`. The handle is verified and stored exactly as posted. Email sends the confirm link now (secret in the URL fragment), at most 5 per transfer, and never stores the secret. Telegram seals the secret and returns a `t.me/?start=` link for the sender to share. No route ever returns the link secret. Refusals: 409 `EMAIL_DISABLED` when email is off, 409 `WEBHOOK_CHANNEL_DISABLED` when the webhook channel is off, 409 `SEALED_CONTACT_DISABLED` when the sealed webhook channel is off, 409 `TELEGRAM_DISABLED` without a bot, 409 `INVALID_REQUEST` for a transfer below the notify minimum, 429 when the per-sender daily email cap is hit. On `https://api.notol.app` only the sealed webhook channel can be on (see `GET /v1/info`). Sealed webhook (D65): send `channel: webhook_sealed` with a `box` instead of `linkSecret`. ToAddress only. The api POSTs `ConfirmSealedEvent` to the payee URL (retries 1, 5, 15, 30 min) and deletes the box on 2xx, give-up, settlement or expiry. It cannot read the secret. Same URL rules and caps as `webhook`. The route notifies on every call where a channel is on, so send an `Idempotency-Key` with a partner key. operationId: postContact parameters: - $ref: '#/components/parameters/TransferId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ContactRequest' } responses: '200': description: Accepted. content: application/json: schema: { $ref: '#/components/schemas/ContactResponse' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/Unprocessable' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/transfers/{id}/claim: post: tags: [signed actions] summary: Claim a ToPerson transfer description: | Flow B, relay only (D56). The PIN never reaches this api: the claimant's browser sets and checks it with the separate co-signer service (`POST /v1/transfers/{id}/pin` and `/cosign`, same error shape), which returns `sigCoSigner`. Steps here, in order: chain read; the claim key's and the co-signer's EIP-712 `Claim` signatures over the same `(id, payTo, displayName)`; relay `claim`. A body with a `pin` field is refused (400). A retry after the relay returns the original `txHash`. operationId: postClaim parameters: - $ref: '#/components/parameters/TransferId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ClaimRequest' } responses: '200': { $ref: '#/components/responses/Tx' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/Unprocessable' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } '503': { $ref: '#/components/responses/Unavailable' } /v1/transfers/{id}/confirm: post: tags: [signed actions] summary: Confirm a ToAddress transfer description: | Flow A. The destination wallet (ERC-1271 allowed) and the contact key (EOA) sign the same `Confirm` message. Both are checked before the api relays `confirm(id, name, signature, sigContact)`. A retry after the relay returns the original `txHash`. operationId: postConfirm parameters: - $ref: '#/components/parameters/TransferId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ConfirmRequest' } responses: '200': { $ref: '#/components/responses/Tx' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/Unprocessable' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } '503': { $ref: '#/components/responses/Unavailable' } /v1/transfers/{id}/reject: post: tags: [signed actions] summary: Reject a transfer and refund the sender description: | Flows A and C. ToAddress or Verified. Signed by the contact key (EOA) with an EIP-712 `Reject`. No destination wallet is needed. Relays `reject(id, sigContact)`. operationId: postReject parameters: - $ref: '#/components/parameters/TransferId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/RejectRequest' } responses: '200': { $ref: '#/components/responses/Tx' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/Unprocessable' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } '503': { $ref: '#/components/responses/Unavailable' } /v1/transfers/{id}/report: post: tags: [signed actions] summary: Report the probe amount (flow C1) description: | Dark unless the api runs with `C1_ENABLED=1`; otherwise 409 `WRONG_MODE`. Live Verified transfer whose probe has no code (a C2 probe is 409 `WRONG_MODE`). Both signatures are checked first: `sigReport` (contact key over `Report{probeId, reported, depositTx}`) and `sigRelease` (contact key over `Confirm{id, recipient, ""}`). Then `depositTx` must be this transfer's own `lockVerified` transaction, with a `Locked` log from the escrow for this id and a USDC `Transfer` log from the sender to the destination for exactly the probe amount, else 422 `WRONG_DEPOSIT`. Then the reported amount must match, else 403 `WRONG_AMOUNT`. A probe that is still Pending counts misses against the same 5 attempts (`attemptsLeft`, then `PIN_LOCKED`). A match gets a co-signed `Attest` relayed as `attestAmount`, then `releaseVerified`. The response is the release tx. A retry on an already Verified probe repeats the checks, counts nothing and goes straight to release. operationId: postReport parameters: - $ref: '#/components/parameters/TransferId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ReportRequest' } responses: '200': { $ref: '#/components/responses/Tx' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '403': { $ref: '#/components/responses/WrongSecretValue' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/Unprocessable' } '423': { $ref: '#/components/responses/Locked' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } '503': { $ref: '#/components/responses/Unavailable' } /v1/transfers/{id}/release: post: tags: [signed actions] summary: Release a Verified transfer (flow C2, or a C1 retry) description: | Signed by the contact key over `Confirm{id, recipient, ""}`. Needs a live Verified transfer whose probe is Verified on chain, else 409 `PROBE_NOT_VERIFIED`. The destination sends `Verifier.reply` itself. The api never relays replies. Relays `releaseVerified`. operationId: postRelease parameters: - $ref: '#/components/parameters/TransferId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ReleaseRequest' } responses: '200': { $ref: '#/components/responses/Tx' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/Unprocessable' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } '503': { $ref: '#/components/responses/Unavailable' } /v1/transfers/{id}/refund: post: tags: [signed actions] summary: Refund an expired transfer description: | No signature. Only once the transfer has expired on chain, else 409 `NOT_EXPIRED`. Relays `refundExpired`. The funds go back to the sender. Works for transfers below the sweep minimum too. A retry returns the original `txHash`. operationId: postRefund parameters: - $ref: '#/components/parameters/TransferId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: false content: application/json: schema: { type: object, additionalProperties: false } responses: '200': { $ref: '#/components/responses/Tx' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadApiKey' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/Unprocessable' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } '503': { $ref: '#/components/responses/Unavailable' } /v1/payees: get: tags: [payees] summary: A sender's payees description: Mirror of verified pairs. One entry per (sender, recipient) pair. operationId: listPayees parameters: - name: sender in: query required: true schema: { $ref: '#/components/schemas/Address' } responses: '200': { $ref: '#/components/responses/Payees' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadApiKey' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/payees/check: get: tags: [payees] summary: Decide how to send to a recipient description: | Returns `direct`, `escrow` or `blocked` for a (sender, recipient) pair. It uses the read limit and needs no key. `direct` is a hint, not an authority. Before a direct send, read `PayeeRegistry.isVerified` on chain yourself. The api never decides where money goes. `self` (sender equals recipient) and `reserved` (the recipient is the zero, escrow, verifier, registry or token address) are `blocked`. `verified_pair` comes from an on-chain read. `lookalike` is checked against the mirror's payee list. If either read fails the route answers 500 `INTERNAL`, never `escrow`. operationId: checkPayee parameters: - name: sender in: query required: true schema: { $ref: '#/components/schemas/Address' } - name: recipient in: query required: true schema: { $ref: '#/components/schemas/Address' } - name: client in: query required: false description: >- Optional, self-reported caller kind for the public aggregate count of checks (`/v1/stats`). Anything else, or none, counts as `other`. Only a daily count per kind is stored, never the addresses. schema: { type: string, enum: [mcp, sdk, web] } responses: '200': description: The decision. content: application/json: schema: { $ref: '#/components/schemas/PayeeCheck' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadApiKey' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/verify/{address}: get: tags: [payees] summary: Pairs where this address is the recipient operationId: verifyAddress parameters: - name: address in: path required: true schema: { $ref: '#/components/schemas/Address' } responses: '200': { $ref: '#/components/responses/Payees' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadApiKey' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/telegram/webhook: post: tags: [system] summary: Telegram bot webhook (called by Telegram, not by integrators) description: | Authorised by the `X-Telegram-Bot-Api-Secret-Token` header. `/start ` only counts in a private chat. It binds the first chat of the Telegram username the sender named, sends the confirm link and deletes the sealed secret. Any other account gets a "different account" note and nothing else. A malformed update is acknowledged with 200. operationId: telegramWebhook security: [] parameters: - name: X-Telegram-Bot-Api-Secret-Token in: header required: true schema: { type: string } requestBody: required: true content: application/json: schema: type: object description: A Telegram Update object. additionalProperties: true responses: '200': description: Acknowledged. content: application/json: schema: type: object required: [ok] properties: ok: { type: boolean, const: true } '401': { $ref: '#/components/responses/BadSignature' } '500': { $ref: '#/components/responses/Internal' } /v1/handles: post: tags: [handles] summary: Register or rebind a handle description: | Dark unless `HANDLES_ENABLED=1`: every handles route answers 404 `HANDLES_DISABLED`. Write limit. Version 1 registers. Version n+1 rebinds, and also needs `previousSignature` from the current owner over the same new binding. The owner signature is checked through ERC-1271 / ERC-6492 aware verification. `issuedAt` must be within 600 seconds of the api clock. The api signs a namespace attestation and stores it. It never decides where money goes: the sender's browser verifies both signatures before it shows an address. One live name per address (D54): registering or rebinding to an address that already has a live name is 409 `ADDRESS_HAS_HANDLE`, and the body's `handle` names that existing name. That answer comes only after the owner signature checks out, so it is no public reverse lookup. Delete the old name first to pick another. Custom domain (D64, needs `NAMESPACES_ENABLED=1`): the domain must have an ordered namespace, else it is a wrong domain (400). Register and rebind need the namespace `active` (else 403 `NAMESPACE_INACTIVE`) and `grant` + `grantSignature` from the namespace's current owner. The grant must equal the binding field for field and `expiresAt` must be in (now, now + 30 days]. No blocklist on a custom domain; the attestation `issuer` is that domain. On our own domain a grant is refused (400). operationId: registerHandle parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/RegisterHandleRequest' } responses: '201': description: Registered or rebound. content: application/json: schema: { $ref: '#/components/schemas/HandleRecord' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '403': { $ref: '#/components/responses/NamespaceInactive' } '409': { $ref: '#/components/responses/Conflict' } '410': { $ref: '#/components/responses/Tombstoned' } '422': { $ref: '#/components/responses/Unprocessable' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/handles/{handle}: get: tags: [handles] summary: Read a handle record description: | Read limit. The record carries `history[]`, oldest first. 404 `NOT_FOUND` for a name that was never registered or belongs to another domain. 410 `HANDLE_TOMBSTONED` for a deleted name. 400 if the path is not a handle. A custom domain (D64) is 404 unless its namespace exists and its DNS checks out; a lapsed payment does not hide names. operationId: getHandle parameters: - $ref: '#/components/parameters/Handle' responses: '200': description: The record. content: application/json: schema: { $ref: '#/components/schemas/HandleRecord' } '400': { $ref: '#/components/responses/InvalidRequest' } '404': { $ref: '#/components/responses/NotFound' } '410': { $ref: '#/components/responses/Tombstoned' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } delete: tags: [handles] summary: Tombstone a handle description: | Terminal: a tombstoned name is never registered again. The body carries the tombstone and the current owner's EIP-712 signature. Write limit. Returns `{ ok: true }`. operationId: deleteHandle parameters: - $ref: '#/components/parameters/Handle' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/DeleteHandleRequest' } responses: '200': description: Tombstoned. content: application/json: schema: type: object required: [ok] properties: ok: { type: boolean, const: true } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '410': { $ref: '#/components/responses/Tombstoned' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/handles/{handle}/availability: get: tags: [handles] summary: Is a handle free description: | Read limit. Always 200 for a well-formed request. A handle in another domain is `invalid`, unless it is a custom-domain namespace (D64) whose DNS checks out; there no name is `blocked`. operationId: handleAvailability parameters: - $ref: '#/components/parameters/Handle' responses: '200': description: The answer. content: application/json: schema: { $ref: '#/components/schemas/HandleAvailability' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/namespaces: post: tags: [namespaces] summary: Order a custom-domain namespace description: | Dark unless `NAMESPACES_ENABLED=1`: every namespaces route answers 404 `NAMESPACES_DISABLED`. Write limit. Add `_notol.` TXT `v=vs1; resolver=; owner=` first. The owner signs `NamespaceOrder { domain, owner, chainId, issuedAt }` (EIP-712, domain `NotolHandles`); `issuedAt` within 600 seconds of the api clock. The api reads the TXT and creates the namespace only if exactly one `v=vs1` record names our resolver, this owner, and no other key (else 422 `NAMESPACE_DNS`, also on a resolver error: try again). Our own domain, a subdomain of it, a lookalike of ours (skeleton contains ours, or first label starts with ours), any punycode `xn--` label, or a domain whose skeleton matches a PAID namespace's is 409 `NAMESPACE_LOOKALIKE` (an unpaid namespace blocks no one; only one per skeleton can be paid). Another owner's namespace is 409 `NAMESPACE_TAKEN`. The same owner again gets 200 and the current view. The namespace starts `unpaid`: names need it `active` (paid, DNS ok). operationId: orderNamespace parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/OrderNamespaceRequest' } responses: '200': description: Already ordered by this owner. content: application/json: schema: { $ref: '#/components/schemas/NamespaceView' } '201': description: Ordered. content: application/json: schema: { $ref: '#/components/schemas/NamespaceView' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/Unprocessable' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/namespaces/{domain}: get: tags: [namespaces] summary: Read a namespace description: Read limit. 404 `NOT_FOUND` if nobody ordered it, 400 if the path is not a domain. operationId: getNamespace parameters: - $ref: '#/components/parameters/Domain' responses: '200': description: The namespace. content: application/json: schema: { $ref: '#/components/schemas/NamespaceView' } '400': { $ref: '#/components/responses/InvalidRequest' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/namespaces/{domain}/recheck: post: tags: [namespaces] summary: Reread a namespace's DNS now description: | Write limit, no body. Rereads `_notol.` TXT. This route can only turn `dnsOk` ON: a matching record sets it true; a definite miss (no record, NXDOMAIN, or a record naming another owner, resolver or key) and a resolver error change nothing. Returns the view after the read. A leader job rereads every namespace every 6 hours and is the only thing that sets `dnsOk` false (on a definite miss). operationId: recheckNamespace parameters: - $ref: '#/components/parameters/Domain' responses: '200': description: The namespace after the read. content: application/json: schema: { $ref: '#/components/schemas/NamespaceView' } '400': { $ref: '#/components/responses/InvalidRequest' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/webhooks: post: tags: [webhooks] summary: Create an event webhook endpoint description: | Needs an API key. Dark unless event webhooks are enabled on the server (404 `WEBHOOKS_DISABLED`). `url` must be https on port 443. The api resolves DNS and refuses loopback, private, link-local, CGNAT, multicast and metadata ranges. It follows no redirects. Give at least one address in `senders` or `recipients`, at most 1000 in total. At most 10 active endpoints per key. A Free-plan key made in the portal cannot create event webhooks: 403 `PLAN_REQUIRED`. The operator moves the key to a paid plan (`pnpm -F @vs/api keys plan growth`). Operator-issued keys and paid keys can. The `secret` is shown once. To rotate, delete the endpoint and create a new one. Deliveries carry `Notol-Signature: t=,v1=`. See `docs/api/partners.md`. operationId: createWebhook security: - bearerAuth: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/WebhookCreateRequest' } responses: '201': description: Created. The secret is in this response only. content: application/json: schema: { $ref: '#/components/schemas/WebhookCreated' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadApiKey' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/Unprocessable' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } get: tags: [webhooks] summary: List this key's webhook endpoints operationId: listWebhooks security: - bearerAuth: [] responses: '200': description: >- The key's endpoints. Never includes secrets. `url` is masked to scheme://host plus `/…` when it has a path or query; the full URL is returned once, at create. content: application/json: schema: type: object required: [webhooks] properties: webhooks: type: array items: { $ref: '#/components/schemas/WebhookEndpointView' } '401': { $ref: '#/components/responses/BadApiKey' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/webhooks/{id}: delete: tags: [webhooks] summary: Delete a webhook endpoint operationId: deleteWebhook security: - bearerAuth: [] parameters: - name: id in: path required: true schema: { type: string, format: uuid } responses: '200': description: Deleted. content: application/json: schema: type: object required: [ok] properties: ok: { type: boolean, const: true } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadApiKey' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/portal/keys: get: tags: [portal] summary: List the owner's API keys operationId: portalListKeys security: - ownerAuth: [] responses: '200': description: Keys this wallet owns, revoked ones included. Never the key itself. content: application/json: schema: type: object required: [keys] properties: keys: type: array items: { $ref: '#/components/schemas/PortalKeyView' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } post: tags: [portal] summary: Create an API key (free plan) description: >- Write `key.create`, target = `name` exactly as sent. At most 3 active keys per owner (409), and at most 10 new keys per IP address per rolling day (429; revoked keys count). operationId: portalCreateKey security: - ownerAuth: [] requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string, minLength: 1, maxLength: 100 } responses: '201': description: Created. `key` is in this response only and is never stored. content: application/json: schema: allOf: - { $ref: '#/components/schemas/PortalKeyView' } - type: object required: [key] properties: key: { type: string } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/portal/keys/{prefix}/revoke: post: tags: [portal] summary: Revoke one of the owner's keys and disable its webhooks description: Write `key.revoke`, target = `prefix`. operationId: portalRevokeKey security: - ownerAuth: [] parameters: - name: prefix in: path required: true schema: { type: string, pattern: '^[A-Za-z0-9_-]{8}$' } responses: '200': description: Revoked. content: application/json: schema: type: object required: [ok] properties: ok: { type: boolean, const: true } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/portal/usage: get: tags: [portal] summary: Usage and amount due per key for a UTC month operationId: portalUsage security: - ownerAuth: [] parameters: - name: month in: query required: false description: yyyy-mm, default the current UTC month. schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } responses: '200': description: Counts and amounts are decimal strings; `due` is USDC base units, null = billed by contract. content: application/json: schema: { $ref: '#/components/schemas/PortalUsage' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/portal/webhooks: get: tags: [portal] summary: Webhook endpoints across the owner's keys, with recent deliveries operationId: portalListWebhooks security: - ownerAuth: [] responses: '200': description: >- Never includes secrets. `url` is masked to scheme://host plus `/…` when it has a path or query (the full URL is shown once, in the create response). Up to 50 newest deliveries per endpoint (kept 7 days). content: application/json: schema: type: object required: [webhooks] properties: webhooks: type: array items: { $ref: '#/components/schemas/PortalWebhookView' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } post: tags: [portal] summary: Create a webhook endpoint on one of the owner's keys description: | Write `webhook.create`, target = `webhookCreateTarget(body)` (sha256 over the fields as sent). Same rules as `POST /v1/webhooks`; 404 `WEBHOOKS_DISABLED` while event webhooks are off, and 403 `PLAN_REQUIRED` for a Free-plan key made in the portal. operationId: portalCreateWebhook security: - ownerAuth: [] requestBody: required: true content: application/json: schema: allOf: - { $ref: '#/components/schemas/WebhookCreateRequest' } - type: object required: [keyPrefix] properties: keyPrefix: { type: string, pattern: '^[A-Za-z0-9_-]{8}$' } responses: '201': description: Created. The secret is in this response only. content: application/json: schema: { $ref: '#/components/schemas/WebhookCreated' } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } /v1/portal/webhooks/{id}: delete: tags: [portal] summary: Delete a webhook endpoint on one of the owner's keys description: Write `webhook.delete`, target = the lowercase id. operationId: portalDeleteWebhook security: - ownerAuth: [] parameters: - name: id in: path required: true schema: { type: string, format: uuid } responses: '200': description: Deleted. content: application/json: schema: type: object required: [ok] properties: ok: { type: boolean, const: true } '400': { $ref: '#/components/responses/InvalidRequest' } '401': { $ref: '#/components/responses/BadSignature' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: ntl_ + 43 base64url characters description: | Partner API key. Optional on the public routes, required on `/v1/webhooks`. Not accepted on `/v1/portal/*`: the portal refuses any `Authorization` header with 400 `INVALID_REQUEST` and takes only `X-Notol-Owner`. Use the key from a server only, never from a browser. It meters and identifies a partner. **It never authorises moving money.** 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. ownerAuth: type: apiKey in: header name: X-Notol-Owner description: | Partner portal (D58), for the web page at `/developers`. base64url of JSON `{access, signature}`: an EIP-712 `PartnerAccess` (domain `NotolPartner`, `packages/shared/src/partner.ts`) signed by the owner wallet. `read` passes are reusable for at most 600 s; every write signs its own action and target and works once (409 `NONCE_USED`). Missing 401 `OWNER_AUTH_REQUIRED`; bad signature, chain, action or target 401 `BAD_SIGNATURE`; outside the window 400 `STALE_SIGNATURE`. Every portal route is 404 `PORTAL_DISABLED` unless enabled. parameters: TransferId: name: id in: path required: true description: Transfer id, 0x + 64 hex. schema: { $ref: '#/components/schemas/Hex32' } Handle: name: handle in: path required: true description: '`name@domain`, URL-encoded.' schema: { type: string, examples: ['alice@example.test'] } Domain: name: domain in: path required: true description: A lowercase hostname with at least two labels. schema: { type: string, examples: ['example.com'] } IdempotencyKey: name: Idempotency-Key in: header required: false description: | 1 to 64 characters of `[A-Za-z0-9_-]`. Needs an API key (scope is per key), else 401 `API_KEY_REQUIRED`. The first request stores its final status and body. A replay returns exactly that, with the header `Idempotent-Replayed: true`. The same key with another route or body is 422 `IDEMPOTENCY_MISMATCH`. A request still in flight is 409 `IDEMPOTENCY_IN_FLIGHT`. 5xx and 429 answers are not stored. Rows are kept 24 hours. schema: { type: string, pattern: '^[A-Za-z0-9_-]{1,64}$' } responses: Tx: description: The relayed transaction. content: application/json: schema: { $ref: '#/components/schemas/TxResponse' } Payees: description: Payee pairs. content: application/json: schema: type: object required: [payees] properties: payees: type: array items: { $ref: '#/components/schemas/PayeeView' } InvalidRequest: description: | 400. `INVALID_REQUEST`, `BAD_LINK_SECRET` or `STALE_SIGNATURE`. content: application/json: schema: { $ref: '#/components/schemas/ApiError' } BadApiKey: description: 401 `BAD_API_KEY`, or `API_KEY_REQUIRED` where a key is needed. content: application/json: schema: { $ref: '#/components/schemas/ApiError' } BadSignature: description: 401 `BAD_SIGNATURE` (also `BAD_API_KEY` or `API_KEY_REQUIRED`). content: application/json: schema: { $ref: '#/components/schemas/ApiError' } WrongSecretValue: description: 403 `WRONG_PIN` or `WRONG_AMOUNT`. `attemptsLeft` is set when the attempt was counted. content: application/json: schema: { $ref: '#/components/schemas/ApiError' } Forbidden: description: 403 `PLAN_REQUIRED`. A Free-plan key made in the portal cannot create event webhooks. content: application/json: schema: { $ref: '#/components/schemas/ApiError' } NotFound: description: 404 `NOT_FOUND`. Also `HANDLES_DISABLED`, `NAMESPACES_DISABLED` or `WEBHOOKS_DISABLED` when that surface is off. content: application/json: schema: { $ref: '#/components/schemas/ApiError' } Conflict: description: | 409. `NOT_PENDING`, `WRONG_MODE`, `EXPIRED`, `NOT_EXPIRED`, `PIN_NOT_SET`, `PIN_ALREADY_SET`, `PROBE_NOT_VERIFIED`, `EMAIL_DISABLED`, `WEBHOOK_CHANNEL_DISABLED`, `SEALED_CONTACT_DISABLED`, `TELEGRAM_DISABLED`, `IDEMPOTENCY_IN_FLIGHT`, `HANDLE_TAKEN`, `HANDLE_LOOKALIKE`, `ADDRESS_HAS_HANDLE` (with `handle`), `NAMESPACE_TAKEN`, `NAMESPACE_LOOKALIKE`, or `INVALID_REQUEST` for a state rule (transfer below the notify minimum, handle rebind state). content: application/json: schema: { $ref: '#/components/schemas/ApiError' } Tombstoned: description: 410 `HANDLE_TOMBSTONED`. content: application/json: schema: { $ref: '#/components/schemas/ApiError' } Unprocessable: description: | 422. `SIMULATION_FAILED` (the message names the decoded revert), `WRONG_DEPOSIT` (`attemptsLeft` when counted), `HANDLE_BLOCKED`, `NAMESPACE_DNS`, or `IDEMPOTENCY_MISMATCH`. content: application/json: schema: { $ref: '#/components/schemas/ApiError' } NamespaceInactive: description: 403 `NAMESPACE_INACTIVE`. The custom-domain namespace is unpaid, lapsed or failing DNS (D64). content: application/json: schema: { $ref: '#/components/schemas/ApiError' } Locked: description: 423 `PIN_LOCKED`. Five wrong PINs, or five wrong C1 reports. Funds stay locked until cancel or expiry. content: application/json: schema: { $ref: '#/components/schemas/ApiError' } RateLimited: description: 429 `RATE_LIMITED`, or `QUOTA_EXCEEDED` when a free-plan key has used its monthly payee checks. content: application/json: schema: { $ref: '#/components/schemas/ApiError' } Internal: description: 500 `INTERNAL`. content: application/json: schema: { $ref: '#/components/schemas/ApiError' } Unavailable: description: 503 `RELAYER_DRY_RUN` or `RELAYER_UNAVAILABLE`. content: application/json: schema: { $ref: '#/components/schemas/ApiError' } schemas: Address: type: string pattern: '^0x[0-9a-fA-F]{40}$' Hex: type: string pattern: '^0x([0-9a-fA-F]{2})*$' Hex32: type: string pattern: '^0x[0-9a-fA-F]{64}$' BaseUnits: type: string pattern: '^[0-9]+$' description: Base units as a decimal string. USDC and EURC have 6 decimals, so `1000000` is 1 USDC. ContactChannel: type: string enum: [email, telegram, webhook, webhook_sealed] description: '`webhook` posts the link to an https URL given as `handle`. Off on `https://api.notol.app`. `webhook_sealed` posts a box only the payee can open (D65).' ApiInfo: type: object required: [chainId, contactChannels] properties: chainId: { type: integer, description: 5042 on Arc mainnet. } contactChannels: type: object description: | Which `POST /v1/transfers/{id}/contact` channels deliver on this api. On `https://api.notol.app` `email`, `webhook` and `telegram` are false (D55). `webhookSealed` (D65) can be true there once enabled. required: [email, webhook, webhookSealed, telegram] properties: email: { type: boolean } webhook: { type: boolean } webhookSealed: { type: boolean, description: 'The `webhook_sealed` channel: the payee gets the secret sealed to its key.' } telegram: { type: boolean } ActivityFeed: type: object required: [items, totals, asOf] properties: items: type: array maxItems: 20 description: Newest first. items: type: object required: [outcome, mode, token, amount, at] properties: outcome: type: string enum: [released, rejected, cancelled, expired] description: '`rejected` = the recipient said "not mine" and the sender was refunded.' mode: { type: string, enum: [ToAddress, ToPerson, Verified] } token: { type: string, enum: [USDC, EURC] } amount: { type: string, pattern: '^[0-9]+$', description: 'Base units (6 decimals), rounded to 2 significant digits.' } at: { type: string, format: date-time, description: Settle time floored to 15 minutes. } totals: type: object required: [released, rejected, cancelled, expired] properties: released: { type: integer } rejected: { type: integer } cancelled: { type: integer } expired: { type: integer } asOf: { type: string, format: date-time, description: 'Cut-off, on a 15-minute boundary at least 15 minutes ago.' } PublicStats: type: object required: [released, returned, rejected, payeeChecks, asOf, checksAsOf] properties: released: type: object required: [count, usdc] description: Released to the recipient. properties: count: { type: integer } usdc: { type: string, pattern: '^[0-9]+$', description: 'USDC base units (6 decimals), rounded to 2 significant digits.' } returned: type: object required: [count, usdc] description: Refunded to the sender, any reason (rejected, cancelled, expired). properties: count: { type: integer } usdc: { type: string, pattern: '^[0-9]+$', description: 'USDC base units (6 decimals), rounded to 2 significant digits.' } rejected: { type: integer, description: 'Of `returned`: the recipient answered "not my address".' } payeeChecks: type: object required: [total, byClient] properties: total: { type: integer } byClient: type: object required: [mcp, sdk, web, other] properties: mcp: { type: integer } sdk: { type: integer } web: { type: integer } other: { type: integer } asOf: { type: string, format: date-time, description: 'Transfer cut-off, as in /v1/activity.' } checksAsOf: { type: string, format: date-time, description: 'Start of the UTC day that contains asOf: checks counted before this instant (whole days only).' } ApiErrorCode: type: string enum: - INVALID_REQUEST - NOT_FOUND - NOT_PENDING - WRONG_MODE - EXPIRED - NOT_EXPIRED - BAD_SIGNATURE - BAD_LINK_SECRET - PIN_NOT_SET - PIN_ALREADY_SET - WRONG_PIN - PIN_LOCKED - WRONG_AMOUNT - WRONG_DEPOSIT - PROBE_NOT_VERIFIED - RATE_LIMITED - RELAYER_DRY_RUN - RELAYER_UNAVAILABLE - SIMULATION_FAILED - INTERNAL - HANDLES_DISABLED - HANDLE_TAKEN - HANDLE_LOOKALIKE - HANDLE_BLOCKED - HANDLE_TOMBSTONED - ADDRESS_HAS_HANDLE - STALE_SIGNATURE - NAMESPACES_DISABLED - NAMESPACE_TAKEN - NAMESPACE_LOOKALIKE - NAMESPACE_DNS - NAMESPACE_INACTIVE - EMAIL_DISABLED - TELEGRAM_DISABLED - BAD_API_KEY - API_KEY_REQUIRED - IDEMPOTENCY_MISMATCH - IDEMPOTENCY_IN_FLIGHT - WEBHOOKS_DISABLED - WEBHOOK_CHANNEL_DISABLED - SEALED_CONTACT_DISABLED - QUOTA_EXCEEDED - PLAN_REQUIRED - PORTAL_DISABLED - OWNER_AUTH_REQUIRED - NONCE_USED ApiError: type: object required: [error, message] properties: error: { $ref: '#/components/schemas/ApiErrorCode' } message: { type: string, description: Never holds a secret. } attemptsLeft: { type: integer, minimum: 0 } handle: type: string description: On 409 `ADDRESS_HAS_HANDLE` only, the address's existing live name (`name@domain`). TransferMode: type: string enum: [ToAddress, ToPerson, Verified] TransferStatus: type: string enum: [Pending, Released, Refunded] RefundReason: type: string enum: [Cancelled, Rejected, Expired] ProbeView: type: object required: [probeId, amount, hasCode, status] properties: probeId: { $ref: '#/components/schemas/Hex32' } amount: { $ref: '#/components/schemas/BaseUnits' } hasCode: type: boolean description: True when a C2 code commitment exists (wallet destination). False means C1 only (exchange deposit address). status: { type: string, enum: [Pending, Verified] } TransferView: type: object required: - id - chainId - mode - status - sender - recipient - contactKey - token - amount - expiresAt - probeId - probe - releasedTo - displayName - refundReason - contact - pinSet - claimLocked - lockTx - settleTx properties: id: { $ref: '#/components/schemas/Hex32' } chainId: { type: integer } mode: { $ref: '#/components/schemas/TransferMode' } status: { $ref: '#/components/schemas/TransferStatus' } sender: { $ref: '#/components/schemas/Address' } recipient: allOf: [{ $ref: '#/components/schemas/Address' }] description: ToAddress and Verified, the destination. ToPerson, the claim-key address. contactKey: oneOf: [{ $ref: '#/components/schemas/Address' }, { type: 'null' }] description: ToAddress and Verified, the one-time contact key's address. ToPerson, null. token: { $ref: '#/components/schemas/Address' } amount: { $ref: '#/components/schemas/BaseUnits' } expiresAt: { type: integer, description: Unix seconds. } probeId: oneOf: [{ $ref: '#/components/schemas/Hex32' }, { type: 'null' }] probe: oneOf: [{ $ref: '#/components/schemas/ProbeView' }, { type: 'null' }] description: Verified mode only. releasedTo: oneOf: [{ $ref: '#/components/schemas/Address' }, { type: 'null' }] displayName: type: [string, 'null'] refundReason: oneOf: [{ $ref: '#/components/schemas/RefundReason' }, { type: 'null' }] contact: oneOf: - type: object required: [channel, masked] properties: channel: { $ref: '#/components/schemas/ContactChannel' } masked: { type: string, description: 'Masked, for example "b***@gmail.com" or "telegram". Never the full handle.' } - type: 'null' pinSet: type: [boolean, 'null'] description: ToPerson, read from the co-signer service (D56); null when that read failed. Other modes false. claimLocked: type: [boolean, 'null'] description: | No claim will be co-signed (5 wrong PINs, ToPerson) or no C1 report relayed (5 wrong reports). The money stays locked on chain until the sender cancels or it expires. Null when the co-signer read failed (ToPerson). lockTx: oneOf: [{ $ref: '#/components/schemas/Hex32' }, { type: 'null' }] settleTx: oneOf: [{ $ref: '#/components/schemas/Hex32' }, { type: 'null' }] ContactRequest: oneOf: - $ref: '#/components/schemas/ContactRequestPlain' - $ref: '#/components/schemas/ContactRequestSealed' ContactRequestPlain: type: object required: [channel, handle, linkSecret, signature] properties: channel: { $ref: '#/components/schemas/ContactChannel' } handle: { type: string, description: Email address or Telegram username, verified and stored exactly as posted. } linkSecret: { type: string, description: 'Base64url link secret. Rejected unless it derives the on-chain contactKey.' } signature: { $ref: '#/components/schemas/Hex' } ContactRequestSealed: type: object description: | D65. The link secret is sealed to the payee's key by the sender; the api stores and forwards the box and cannot open it. ToAddress transfers only. The signature is an EIP-191 `contactMessage` over `Channel: webhook_sealed`, `Handle: ` and `Box: <0x sha256 of the box bytes>`. required: [channel, handle, box, signature] properties: channel: { type: string, const: webhook_sealed } handle: { type: string, description: 'The payee https URL (port 443, no private hosts), stored exactly as posted.' } box: { type: string, minLength: 114, maxLength: 114, description: 'Base64url, 85 bytes.' } signature: { $ref: '#/components/schemas/Hex' } ContactResponse: type: object required: [ok, telegramStartLink] properties: ok: { type: boolean, const: true } telegramStartLink: type: [string, 'null'] description: For telegram, the sender shares this link with the recipient. The bot replies with the confirm link. ConfirmRequest: type: object required: [displayName, signature, sigContact] description: Both signatures cover the same `Confirm{id, recipient, displayName}`. properties: displayName: { type: string } signature: { allOf: [{ $ref: '#/components/schemas/Hex' }], description: By the destination. } sigContact: { allOf: [{ $ref: '#/components/schemas/Hex' }], description: By the contact key. } RejectRequest: type: object required: [sigContact] properties: sigContact: { allOf: [{ $ref: '#/components/schemas/Hex' }], description: 'Contact key over Reject{id, recipient}.' } ClaimRequest: type: object additionalProperties: false required: [payTo, displayName, sigClaimKey, sigCoSigner] description: Both signatures cover the same `Claim{id, payTo, displayName}`. No PIN (D56). properties: payTo: { $ref: '#/components/schemas/Address' } displayName: { type: string } sigClaimKey: { allOf: [{ $ref: '#/components/schemas/Hex' }], description: By the claim key (the link). } sigCoSigner: { allOf: [{ $ref: '#/components/schemas/Hex' }], description: By the co-signer service, after the PIN. } ReportRequest: type: object required: [reported, depositTx, sigReport, sigRelease] properties: reported: { $ref: '#/components/schemas/BaseUnits' } depositTx: allOf: [{ $ref: '#/components/schemas/Hex32' }] description: The transaction hash of the deposit the recipient sees in their exchange history. sigReport: { allOf: [{ $ref: '#/components/schemas/Hex' }], description: 'Contact key over Report{probeId, reported, depositTx}.' } sigRelease: { allOf: [{ $ref: '#/components/schemas/Hex' }], description: 'Contact key over Confirm{id, recipient, ""}.' } ReleaseRequest: type: object required: [sigRelease] properties: sigRelease: { allOf: [{ $ref: '#/components/schemas/Hex' }], description: 'Contact key over Confirm{id, recipient, ""}.' } TxResponse: type: object required: [txHash] properties: txHash: { $ref: '#/components/schemas/Hex32' } PayeeView: type: object required: [sender, recipient, displayName, firstVerifiedAt, lastVerifiedAt, count] properties: sender: { $ref: '#/components/schemas/Address' } recipient: { $ref: '#/components/schemas/Address' } displayName: { type: [string, 'null'] } firstVerifiedAt: { type: integer, description: Unix seconds. } lastVerifiedAt: { type: integer, description: Unix seconds. } count: { type: integer } PayeeDecision: type: string enum: [direct, escrow, blocked] PayeeCheckReason: type: string enum: [verified_pair, new_payee, lookalike, self, reserved] PayeeCheck: type: object required: [decision, reason, verified, lookalikeOf] description: A hint, never an authority. Re-read `PayeeRegistry.isVerified` on chain before a direct send. properties: decision: { $ref: '#/components/schemas/PayeeDecision' } reason: { $ref: '#/components/schemas/PayeeCheckReason' } verified: oneOf: [{ $ref: '#/components/schemas/PayeeView' }, { type: 'null' }] description: Chain read of `PayeeRegistry.getPayee`, never the mirror. Null unless verified. lookalikeOf: oneOf: [{ $ref: '#/components/schemas/Address' }, { type: 'null' }] description: The sender's verified payee that `recipient` imitates, when reason is `lookalike`. WebhookEventType: type: string enum: [transfer.locked, transfer.released, transfer.refunded] WebhookCreateRequest: type: object required: [url, events] properties: url: { type: string, format: uri, description: https, port 443. } events: type: array items: { $ref: '#/components/schemas/WebhookEventType' } senders: type: array items: { $ref: '#/components/schemas/Address' } recipients: type: array items: { $ref: '#/components/schemas/Address' } description: At least one address in `senders` or `recipients`, at most 1000 in total. Empty means any on that side. WebhookEndpointView: type: object required: [id, url, events, senders, recipients, createdAt, disabled] properties: id: { type: string, format: uuid } url: { type: string } events: type: array items: { $ref: '#/components/schemas/WebhookEventType' } senders: type: array items: { $ref: '#/components/schemas/Address' } recipients: type: array items: { $ref: '#/components/schemas/Address' } createdAt: { type: integer, description: Unix seconds. } disabled: type: boolean description: Set after 8 straight failed deliveries. WebhookCreated: allOf: - $ref: '#/components/schemas/WebhookEndpointView' - type: object required: [secret] properties: secret: type: string description: Signs every delivery. Shown once. To rotate, delete the endpoint and create another. examples: [ntl_EXAMPLE_not_a_real_webhook_secret] PartnerPlan: type: string enum: [free, growth, enterprise] PortalKeyView: type: object required: [prefix, name, plan, rateLimitPerMin, createdAt, revokedAt] properties: prefix: { type: string } name: { type: string } plan: { $ref: '#/components/schemas/PartnerPlan' } rateLimitPerMin: { type: integer } createdAt: { type: integer, description: Unix seconds. } revokedAt: { type: [integer, 'null'] } PortalUsage: type: object required: [month, included, keys] properties: month: { type: string } included: type: object description: What the free plan covers per month. required: [settled, checks] properties: settled: { type: string } checks: { type: string } keys: type: array items: type: object required: [prefix, name, plan, revoked, settled, checks, due] properties: prefix: { type: string } name: { type: string } plan: { $ref: '#/components/schemas/PartnerPlan' } revoked: { type: boolean } settled: { type: string } checks: { type: string } due: { type: [string, 'null'] } PortalWebhookView: allOf: - $ref: '#/components/schemas/WebhookEndpointView' - type: object required: [keyPrefix, deliveries] properties: keyPrefix: { type: string } deliveries: type: array items: type: object required: [eventId, type, transferId, status, attempts, lastError, createdAt, deliveredAt, nextAttemptAt] properties: eventId: { type: string } type: { $ref: '#/components/schemas/WebhookEventType' } transferId: { $ref: '#/components/schemas/Hex32' } status: { type: string, enum: [pending, delivered, failed] } attempts: { type: integer } lastError: { type: [string, 'null'], description: The api's own short reason, never the receiver's body. } createdAt: { type: integer } deliveredAt: { type: [integer, 'null'] } nextAttemptAt: { type: [integer, 'null'] } WebhookEvent: type: object description: | The body Notol POSTs to your endpoint. Not a route of this api. Header `Notol-Signature: t=,v1=` is HMAC-SHA256 of `"."` under the endpoint secret. required: [id, type, createdAt, data] properties: id: { type: string, description: 'Stable per event, for example `evt_…`. Dedupe replays by it.' } type: { $ref: '#/components/schemas/WebhookEventType' } createdAt: { type: integer } data: allOf: [{ $ref: '#/components/schemas/TransferView' }] description: The current transfer view at send time. ConfirmSealedEvent: type: object description: | The body Notol POSTs to the payee URL of a `webhook_sealed` contact (D65). Not a route of this api, and not signed by an endpoint secret. Nothing in it is trusted: the payee opens the box with its private contact box key and checks the transfer on its own RPC. A 2xx deletes the box from the api. required: [type, transferId, chainId, box] properties: type: { type: string, const: confirm.sealed } transferId: { $ref: '#/components/schemas/Hex32' } chainId: { type: integer } box: { type: string, minLength: 114, maxLength: 114, description: 'Base64url, 85 bytes. The link secret sealed to the payee key.' } HandleBinding: type: object required: [name, domain, address, chainId, version, issuedAt] properties: name: { type: string, minLength: 3, maxLength: 32 } domain: { type: string } address: { $ref: '#/components/schemas/Address' } chainId: { type: integer } version: { type: integer, minimum: 1, maximum: 1000, description: 1 registers. n+1 rebinds. } issuedAt: { type: integer, description: Unix seconds. } NamespaceAttestation: type: object required: [bindingHash, kycLevel, issuer] properties: bindingHash: { $ref: '#/components/schemas/Hex32' } kycLevel: { type: integer, description: 0 in H1. } issuer: { type: string } HandleTombstone: type: object required: [name, domain, address, chainId, version, issuedAt] properties: name: { type: string } domain: { type: string } address: { $ref: '#/components/schemas/Address' } chainId: { type: integer } version: { type: integer, description: "The live binding's version. Any other value is 409 INVALID_REQUEST." } issuedAt: { type: integer } HandleHistoryEntry: type: object required: [version, address, issuedAt, changedAt, signature, previousSignature] properties: version: { type: integer } address: { $ref: '#/components/schemas/Address' } issuedAt: { type: integer } changedAt: { type: integer, description: The api's clock when it stored the row. Unsigned. } signature: { $ref: '#/components/schemas/Hex' } previousSignature: oneOf: [{ $ref: '#/components/schemas/Hex' }, { type: 'null' }] description: Previous owner's signature over this binding. Null for version 1. HandleRecord: type: object required: [handle, binding, signature, attestation, namespaceSignature, namespaceKey, history] properties: handle: { type: string, description: '`name@domain`.' } binding: { $ref: '#/components/schemas/HandleBinding' } signature: { allOf: [{ $ref: '#/components/schemas/Hex' }], description: Owner over HandleBinding. } attestation: { $ref: '#/components/schemas/NamespaceAttestation' } namespaceSignature: { allOf: [{ $ref: '#/components/schemas/Hex' }], description: Namespace key over NamespaceAttestation. } namespaceKey: { $ref: '#/components/schemas/Address' } history: type: array description: Oldest first. items: { $ref: '#/components/schemas/HandleHistoryEntry' } grant: allOf: [{ $ref: '#/components/schemas/NamespaceGrant' }] description: | Custom domain only (D64), absent on ours. The namespace owner's grant for the live binding version (equals the binding field for field). Verifiers check `grantSignature` recovers to the owner named in the domain's `_notol` TXT and `binding.issuedAt <= grant.expiresAt`. grantSignature: allOf: [{ $ref: '#/components/schemas/Hex' }] description: Custom domain only. The namespace owner's EIP-712 signature over `grant`. RegisterHandleRequest: type: object required: [binding, signature] properties: binding: { $ref: '#/components/schemas/HandleBinding' } signature: { $ref: '#/components/schemas/Hex' } previousSignature: allOf: [{ $ref: '#/components/schemas/Hex' }] description: Required when `binding.version` is above 1. grant: allOf: [{ $ref: '#/components/schemas/NamespaceGrant' }] description: Custom domain only (D64), required there. Must equal the binding field for field. grantSignature: allOf: [{ $ref: '#/components/schemas/Hex' }] description: The namespace owner's EIP-712 signature over `grant`. Custom domain only, required there. NamespaceGrant: type: object required: [domain, name, address, chainId, version, expiresAt] properties: domain: { type: string } name: { type: string } address: { $ref: '#/components/schemas/Address' } chainId: { type: integer } version: { type: integer, minimum: 1, maximum: 1000 } expiresAt: { type: integer, description: 'Unix seconds, in (now, now + 2592000].' } NamespaceOrder: type: object required: [domain, owner, chainId, issuedAt] properties: domain: { type: string } owner: { $ref: '#/components/schemas/Address' } chainId: { type: integer } issuedAt: { type: integer, description: Unix seconds. } OrderNamespaceRequest: type: object required: [order, signature] properties: order: { $ref: '#/components/schemas/NamespaceOrder' } signature: { allOf: [{ $ref: '#/components/schemas/Hex' }], description: The owner over NamespaceOrder. } NamespaceView: type: object required: [domain, owner, state, paidUntil, dnsOk, dnsCheckedAt] properties: domain: { type: string } owner: { $ref: '#/components/schemas/Address' } state: type: string enum: [active, unpaid, lapsed, dns_failed] description: '`active` = DNS ok and paid: names can be granted, registered and moved.' paidUntil: oneOf: [{ type: integer }, { type: 'null' }] description: Unix seconds, null before the first payment. dnsOk: { type: boolean } dnsCheckedAt: { type: integer, description: Unix seconds of the last definite DNS read. } DeleteHandleRequest: type: object required: [tombstone, signature] properties: tombstone: { $ref: '#/components/schemas/HandleTombstone' } signature: { $ref: '#/components/schemas/Hex' } HandleAvailability: type: object required: [handle, available, reason] properties: handle: { type: string } available: { type: boolean } reason: { type: string, enum: [available, invalid, blocked, taken, lookalike, tombstoned] }