Docs

Run a service

This guide turns your code, or an API you already run, into a TapeAPI service anyone can verify. It covers the concepts, a local run, going live (from a phone, or from a server), renewal and operations.

Already run an MCP server? A signing proxy lets you keep your server and your domain and add an on-chain identity, tool definitions pinned on chain and a signature on every result: see Tape out your own MCP server. The identity, delegation and publishing steps are the ones below.

The five things a live service needs

ThingWhat it isWho makes it
CircuitAn NFT minted on a TapeOut processor. Whoever holds it owns the service.You, on tapeout.net.
ContainerThe circuit's ERC-6551 account: the service's identity and address. It must be opened (0.012 BNB) before its site accepts files.You, on the circuit's page.
Signing keyA hot key your server signs every answer with. It is not your wallet and holds no funds.Generated for you (console) or by you.
DelegationAn EIP-712 signature by the circuit's holder: "this signing key speaks for my container until this date". No funds move.The holder's wallet.
Manifest.well-known/tapeapi.json in the container's site: endpoints, methods, prices, signing key and delegation.Written on chain by the holder (one transaction).

The container address is fixed by the circuit, so the manifest is the only thing you ever republish: to add methods, change the endpoint, or renew the delegation.

1. Write the service and run it locally

createProvider does the protocol for you: request parsing, the signed envelope, errors, rate limits and, for paid methods, voucher checks and metering. You write plain functions:

import { readFile } from 'node:fs/promises'
import { createProvider } from '@tapeapi/server'

const provider = createProvider({
  manifest: JSON.parse(await readFile('manifest.json', 'utf8')),
  signerKey: process.env.SIGNER_KEY,
  dev: true,                                    // local run: http endpoint, no delegation yet; remove it to go live
  rpcUrls: ['https://bsc-dataseed.bnbchain.org', 'https://bsc-mainnet.public.blastapi.io', 'https://rpc-bsc.48.club'],
  quorum: 2,
  methods: {
    blockNumber: async (_params, ctx) => ({ blockNumber: ctx.block }),
    quote: async ({ symbol }) => {
      if (!/^[A-Z]{2,10}$/.test(symbol)) throw Object.assign(new Error('bad symbol'), { code: 'BAD_REQUEST' })
      return fetch(`https://your.api/quote/${symbol}`).then((r) => r.json())
    },
  },
})
await provider.listen(8787)                     // Node
// On Cloudflare Workers: export default { fetch: (request) => provider.handleRequest(request) }

Start from a working example rather than a blank file:

dev: true is what lets it run locally: an http endpoint, no delegation, priced methods whose payment.escrow is the zero address (their paid calls are refused). A manifest's own dev field switches nothing; remove the option when you go live. Clients connect to a dev service with createTapeAPI({ dev: true }). Check it against the protocol with the black-box conformance suite:

node conformance/run.mjs --url http://127.0.0.1:8787

A dev service reports NOT CONFORMANT, and that is expected: it has no delegation (a MUST), and methods that need parameters are skipped unless you pass them with --params. Run the suite again once the service is live with a holder delegation; that is the result that counts.

2. Go live from a phone (Cloudflare and holder console)

This is the path with no server and no command line.

  1. Circuit and container. On tapeout.net: create a processor, Tape Out a circuit, open its container (0.012 BNB). The container's .tape name works by itself; no name binding is needed.
  2. Deploy the service. In the Cloudflare dashboard: Workers & Pages → Create → Workers → Import a repository → your fork of this repository. Build command npm ci, deploy command npm run deploy:provider. The Worker's name in the dashboard must match name in your fork's examples/cloudflare-worker/wrangler.toml (my-tapeapi-service; change both if you want another). The Worker starts in setup mode and answers only its health check.
  3. Give it your own hostname. The endpoint is written into the on-chain manifest, so use a hostname you control, such as api.yourdomain.com: in the dashboard, Worker → Settings → Domains & Routes → Add → Custom domain. A Workers custom domain needs that hostname's zone (yourdomain.com) on the same Cloudflare account. If you have no domain on Cloudflare, use the Worker's own https://<worker name>.<your account>.workers.dev URL instead (still https). Then add the variable PUBLIC_URL = https://<your host> (Settings → Variables and Secrets). Setup mode lists PUBLIC_URL as missing until it is set.
  4. Open the holder console at tapeapi.fun/console in your wallet's in-app browser, and follow its five steps:
    • 1 connects the wallet that holds the circuit;
    • 2 reads your circuit, checks you are the holder and that the container is opened;
    • 3 generates the signing key on your phone and shows the variables to add in Cloudflare (SIGNER_KEY as a secret; the rest are public);
    • 4 checks the service reports that same key, asks your wallet for the delegation signature, and checks the signature really comes from the holder. In its service URL field, replace the default https://api.tapeapi.fun with https://<your host>. Add the two values it shows, DELEGATION_EXPIRES and DELEGATION_SIG, as variables;
    • 5 builds the manifest from what you read and signed, requires the service's copy to match it field for field, shows it to you, and writes it on chain with one transaction.

Do all five console steps in the same wallet app; the page keeps its progress in that browser. Never screenshot or send the signing key to anyone.

npm run deploy:public deploys the project's own public service (api.tapeapi.fun, from examples/public-api/). It is not for forks: use npm run deploy:provider.

3. Go live from a server

  1. Generate a signing key and keep it in your secret store.
  2. Have the holder sign the delegation. The key never needs to leave the wallet:

    node examples/reader-service/sign-delegation.mjs --container 0x<container> --signer 0x<signing key address> --expires <unix time>
    # sign the printed typed data with the holder's wallet (eth_signTypedData_v4; a Safe signs via EIP-1271), then
    node examples/reader-service/sign-delegation.mjs --container 0x<container> --signer 0x<address> --expires <unix time> --sig 0x<signature>
  3. Run the service over HTTPS with SIGNER_KEY, CIRCUITS, TOKEN_ID, CONTAINER, DELEGATION_EXPIRES, DELEGATION_SIG and PUBLIC_URL. A live manifest must advertise an https:// endpoint.
  4. Publish the manifest: api.tx.publishManifest({ container, manifest }) returns { txs, key, size, sha256Hash }: txs are the transactions for the holder's wallet to send in order (a SiteRegistry.putFile, then an appendChunk per further 24,000 bytes). The key is .well-known/tapeapi.json, with no leading slash.

Check it the way any client would:

const svc = await createTapeAPI({ rpcUrls: [/* ... */], quorum: 2 }).resolve('0x<container>')

4. Renew

A delegation lasts 30 days by default (90 before 3.0); in console step 4 you can type any whole number of days from 1 to 366 instead, such as 7 or 90. tapeapi-doctor warns from 7 days left. Clients read it from the on-chain manifest, so renewing means a new signature and publishing the manifest again. The signing key stays the same. In the holder console:

  1. Step 2: read your circuit. The console also reads the manifest already on chain and the signing address it authorises.
  2. Step 4: tap Renew. It signs a new delegation for the key already published; no new key is generated, and step 3 is skipped. The service URL must be the one on chain.
  3. In Cloudflare, replace DELEGATION_EXPIRES and DELEGATION_SIG with the two new values and wait for the redeploy.
  4. Step 5: publish the manifest again.

There is no revocation: an old delegation stays valid until its own expiry, so if the signing key leaks, do not renew: generate a new key in step 3, sign for it in step 4 and publish in step 5 at once.

5. Paid methods

Experimental. Payments (TAPI-22 payment channels and the escrow contract) are not deployed or audited and are outside the compatibility promise: the names below may change in a minor release. See Upgrading to 1.0.

Set priceBEM on a method and name an escrow in payment. The runtime verifies each voucher, meters per consumer and refuses anything below the price. Every endpoint of one paid service must share one atomic meter store (D1 on Cloudflare, see examples/cloudflare-worker/), or the same voucher could be served twice. A settler submits vouchers to the escrow in batches. (Experimental, not deployed: the escrow planned for audit holds one token per instance and starts with BEM, so the first instance's unit is the one priceBEM uses (10⁻⁸ BEM); the USDT-pegged token and WBNB are planned on demand. Escrowed amounts are read from the token's own decimals(), never hard-coded to BEM's 8; priceBEM is unchanged and is still the only per-call price field. Which tokens and why: docs/FEES.md.) Until a manifest can price in another token, the SDK refuses to fund or pay a service whose escrow holds anything but BEM (UNSUPPORTED_PAYMENT_TOKEN; the escrow contract itself takes any admitted token, the manifest's multi-token pricing is not yet specified), and it builds approve and fund only for an audited escrow or one the consumer adds explicitly.

The escrow's token is checked before the first paid call (3.0). The runtime reads the escrow's token() and compares it with the token the manifest prices in (BEM on BNB Smart Chain, or createProvider({ escrowToken }) on a local chain), on every path: listen() at start-up, and on Cloudflare Workers (handleRequest) before the first paid call, so a Worker no longer calls checkEscrowToken() itself. An escrow of another token, an escrow without token(), and nodes that cannot read it all refuse paid calls with an unsigned HTTP 503 (UNSUPPORTED_PAYMENT_TOKEN, data.reason escrow-token-mismatch, escrow-no-token or escrow-token-unreadable): nothing is signed or billed, and free methods answer as before. A refused call is unverified traffic and counts against the caller's free budget. A definite answer (a match, a mismatch, or a skip) is kept for the life of the instance, and calling provider.checkEscrowToken() yourself returns it rather than reading again; a read that failed is tried again at the next paid call and is never taken for a match. For a caller the three reasons therefore differ: escrow-token-mismatch is permanent for that instance (a retry gets the same 503 without a new read, so it is not worth retrying; only redeploying with the right escrow or configuration changes it), while escrow-no-token and escrow-token-unreadable are read again at the next paid call. The answer is unsigned, so it proves nothing: the SDK reports it as PROVIDER_UNAVAILABLE (unsigned claim: UNSUPPORTED_PAYMENT_TOKEN) and releases the call's reservation (TAPI-22 §3.2, informative note). listen() still refuses to start on a mismatch, dev: true still skips the check, and provider.stats().escrowTokenRefused counts the refusals. The check is also skipped, and paid calls go on without it, when no method has a price, when no escrow is configured, when settlementFacts is configured (the settlement contract is not read) or enforcement is (the enforcement backend checks its own channels and token), when there are no rpcUrls, and on a chain other than BNB Smart Chain unless you pass createProvider({ escrowToken }) (there is no default token there). checkEscrowToken() then answers skipped with the reason. The escrow contract is not audited and not deployed, so no live service takes payment today.

At settlement, a maintenance contribution comes out of your share, never out of the consumer's price. It defaults to 1% (100 bps, a constant in the escrow). You can set it to 0 to turn it off, or to anything up to 20%, for your own service with api.tx.setContribution({ circuits, tokenId, bps }), sent from the circuit holder's wallet. A service at 0 gets exactly the same treatment. See docs/FEES.md. Each escrow instance keeps its own setting: with several instances, set it on each (escrow option) or on all of them at once with api.tx.setContributionAll({ circuits, tokenId, bps, escrows }) (one transaction per instance; experimental), and tapeapi-doctor --escrows <a,b> notes when they differ.

The escrow is not deployed on mainnet yet. Paid methods run against the examples today, and nothing is charged. The reference escrow in the repository already applies the 1% default; it is not audited.

6. Operate

docs/OPERATING.md covers keys, monitoring, RPC choice, rate limits (600 free calls per minute per IP by default) and relay capacity. Two rules worth repeating:

  • Use at least three RPC nodes with quorum: 2: one node down or refusing a method still leaves a quorum.
  • Keep the signing key only where the service runs. Anyone who can change the service's code can read it.

Edit this page on GitHub