CrawlerToll

Pay for and read sealed articles (AI agents)

This page is for developers who build AI agents. It shows how your agent pays for one sealed article and reads it. Everything happens over plain HTTP, so any language works. The script below is complete and runs with Node.

@crawlertoll/sdk will wrap the same steps in one call. It is not on npm yet, so this page does not need it.

What you get

One paid request opens one sealed article. Your agent buys the full article and gets its full text. The publisher is paid directly, in USDC on Base. Charthouse never holds the money.

Where a publisher offers it, you can also buy an AI-training licence for an article. See AI-training licence below.

Before you start

  • Node 22 or later, and viem for signing: npm i viem.
  • A wallet key that holds USDC on Base. Your agent signs each payment with it. Only the signed payment is sent, never the key.
  • A spending cap: the most you will pay for one article, in USDC micros (1,000,000 micros is 1 USDC). The script refuses to sign anything above it.

How it works

  1. Fetch the article with your agent's user agent. A sealed article is a normal page with its text encrypted: the encrypted text is in a <script type="application/ct-sealed+json"> element, and the Link response header names where to buy the key, as Link: <...>; rel="payment"; part="full". The article must be https, after any redirects, and the sealed article's content id must belong to the site that served it.
  2. Ask for the key without paying. POST {} to the payment link. The unlock service answers 402 with a signed offer (ct_offer_v1): the price, who is paid, and the USDC contract to pay with.
  3. Check the offer before you sign anything:
    • the signature is Ed25519 over "ct_offer_v1:" plus the offer as canonical JSON (keys sorted, without signature, meter, pass_expired and pack_exhausted), made with an active key published at https://registry.crawlertoll.com/.well-known/crawlertoll-keys.json;
    • the offer is for the article you fetched, and signed for the same site (with or without www.);
    • it has not expired;
    • the asset is USDC on Base, and the price is within your cap.
  4. Sign a USDC transfer (EIP-3009 TransferWithAuthorization) for exactly the offered amount, using the offer's x402.eip712 domain. Make the nonce the SHA-256 of a random secret you keep: the secret is your replay_secret.
  5. Send the payment. The same POST again, now with the signed transfer in an x402 V2 PAYMENT-SIGNATURE header and {"replay_secret": "0x..."} as the body. The service settles the payment and answers 200 with the article key as cek. A full-price unlock also returns a pass for the article; a micro unlock (a very small price) returns none.
  6. Decrypt on your side: AES-256-GCM with the key, the blob's nonce, and the content id as associated data.

Keep the key. It opens the same article again without paying.

The script

// agent.mjs: pay for and read one CrawlerToll sealed article over plain HTTP.
// Node 22+, plus viem for signing:  npm i viem
// Dry run (no payment):  node agent.mjs https://www.crawlertoll.com/demo/micro.html
// Pay:                   PAY=1 AGENT_PRIVATE_KEY=0x... node agent.mjs <url>
import { privateKeyToAccount } from "viem/accounts";
 
const URL_ = process.argv[2];
const MAX_MICROS = 50_000; // your spending cap: never pay more than 0.05 USDC
const UA = "MyAgent/1.0 (+https://example.com/agent)"; // your real crawler user agent
const KEYS_URL = "https://registry.crawlertoll.com/.well-known/crawlertoll-keys.json";
const USDC = { "eip155:8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "eip155:84532": "0x036CbD53842c5426634e7929541eC2318f3dCF7e" };
 
const b64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
const hex = (u) => [...u].map((x) => x.toString(16).padStart(2, "0")).join("");
const site = (h) => h.toLowerCase().replace(/^www\./, "");
const canonical = (v) => v === null || typeof v !== "object" ? JSON.stringify(v)
  : Array.isArray(v) ? "[" + v.map(canonical).join(",") + "]"
  : "{" + Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}";
const fail = (msg) => { throw new Error(msg + ": not paying."); };
 
// 1. Fetch the article: the encrypted text is in the page, the key URL in the Link header.
const page = await fetch(URL_, { headers: { "User-Agent": UA } });
const host = new URL(page.url).host;
if (!page.url.startsWith("https://") || site(host) !== site(new URL(URL_).host)) fail("redirected off https or to another site");
const html = await page.text();
const blob = JSON.parse(html.match(/<script type="application\/ct-sealed\+json"(?![^>]*data-part)[^>]*>([\s\S]*?)<\/script>/)?.[1] ?? fail("no sealed article"));
if (site(blob.content_id.split("/")[0]) !== site(host)) fail("the sealed article belongs to another site");
const keyUrl = (page.headers.get("Link") || "").split(/,(?=\s*<)/)
  .map((l) => l.match(/<([^>]+)>(.*)/)).find((m) => m && /rel="?payment"?/.test(m[2]) && /part="?full"?/.test(m[2]) && !/tier=/.test(m[2]))?.[1] ?? fail("no payment link");
if (new URL(keyUrl).host !== "registry.crawlertoll.com") fail("the payment link is not the CrawlerToll registry");
 
// 2. Ask for the key with no payment: the answer is a 402 with a signed offer.
const q = await fetch(keyUrl, { method: "POST", redirect: "error", headers: { "Content-Type": "application/json", "User-Agent": UA }, body: "{}" });
if (q.status !== 402) fail(`expected a 402 offer, got ${q.status}`);
const offer = await q.json();
 
// 3. Check the offer before signing anything.
const { keys } = await (await fetch(KEYS_URL, { redirect: "error" })).json();
const k = keys.find((x) => x.kid === offer.kid && x.alg === "Ed25519" && x.status === "active") ?? fail("unknown signing key");
const signed = Object.fromEntries(Object.entries(offer).filter(([n]) => !["signature", "meter", "pass_expired", "pack_exhausted"].includes(n)));
const pub = await crypto.subtle.importKey("raw", b64(k.public_key), { name: "Ed25519" }, false, ["verify"]);
if (!(await crypto.subtle.verify({ name: "Ed25519" }, pub, b64(offer.signature), new TextEncoder().encode("ct_offer_v1:" + canonical(signed))))) fail("bad offer signature");
if (offer.content_id !== blob.content_id || site(offer.publisher) !== site(host)) fail("the offer is for another article or publisher");
if (Date.now() > Date.parse(offer.expires_at)) fail("the offer expired");
const x = offer.x402 ?? fail("no USDC offer");
if (USDC[x.networkCaip2]?.toLowerCase() !== x.asset.toLowerCase() || x.eip712.verifyingContract.toLowerCase() !== x.asset.toLowerCase()) fail("not USDC on Base");
const micros = Number(x.priceMicros);
console.log(`${offer.content_id}: ${micros / 1e6} USDC to ${x.payTo} on ${x.networkCaip2}`);
if (!(micros > 0 && micros <= MAX_MICROS)) fail(`price is above your cap of ${MAX_MICROS / 1e6} USDC`);
if (!process.env.PAY) { console.log("Dry run: set PAY=1 to pay."); process.exit(0); }
 
// 4. Sign a USDC transfer for exactly the offered amount. The nonce is the
//    SHA-256 of a secret you keep, so a lost response can be sent again.
const account = privateKeyToAccount(process.env.AGENT_PRIVATE_KEY);
const secret = crypto.getRandomValues(new Uint8Array(32));
const nonce = "0x" + hex(new Uint8Array(await crypto.subtle.digest("SHA-256", secret)));
const authorization = { from: account.address, to: x.payTo, value: String(micros), validAfter: "0", validBefore: String(Math.floor(Date.now() / 1000) + 600), nonce };
const signature = await account.signTypedData({
  domain: x.eip712,
  types: { TransferWithAuthorization: [
    { name: "from", type: "address" }, { name: "to", type: "address" }, { name: "value", type: "uint256" },
    { name: "validAfter", type: "uint256" }, { name: "validBefore", type: "uint256" }, { name: "nonce", type: "bytes32" },
  ] },
  primaryType: "TransferWithAuthorization",
  message: authorization,
});
const paymentHeader = btoa(JSON.stringify({
  x402Version: 2, scheme: "exact", network: x.networkCaip2,
  accepted: { scheme: "exact", network: x.networkCaip2, amount: String(micros), asset: x.asset, payTo: x.payTo, maxTimeoutSeconds: 300, extra: { name: x.eip712.name, version: x.eip712.version } },
  payload: { signature, authorization },
  extensions: {},
}));
const body = JSON.stringify({ replay_secret: "0x" + hex(secret) });
 
// 5. Send the payment. Keep paymentHeader and body until you have the key:
//    sending the same two again never charges twice.
const r = await fetch(keyUrl, { method: "POST", redirect: "error", headers: { "Content-Type": "application/json", "User-Agent": UA, "PAYMENT-SIGNATURE": paymentHeader }, body });
if (r.status !== 200) throw new Error(`HTTP ${r.status}: see "If something goes wrong"`);
const { cek } = await r.json(); // keep this key: it opens the article again without paying
 
// 6. Decrypt on your side: AES-256-GCM, with the content id as associated data.
const key = await crypto.subtle.importKey("raw", b64(cek), "AES-GCM", false, ["decrypt"]);
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(blob.nonce), additionalData: new TextEncoder().encode(blob.content_id) }, key, b64(blob.ciphertext));
console.log(new TextDecoder().decode(plain));

Run it once without paying. It prints the price and who would be paid, then stops:

node agent.mjs https://www.crawlertoll.com/demo/micro.html

Then pay:

PAY=1 AGENT_PRIVATE_KEY=0x... node agent.mjs https://www.crawlertoll.com/demo/micro.html

Set UA to the user agent your crawler really uses, and send the same one on every request. The unlock service uses it to quote you the right price.

Practise on Base Sepolia

The USDC on Base Sepolia is free: get some from faucet.circle.com. The test article is https://www.crawlertoll.com/demo/testnet.html. It costs 0.10 test USDC, and its key is sold by the test unlock service, not by registry.crawlertoll.com. To use it, change three lines in the script:

  • MAX_MICROS to 100_000;
  • KEYS_URL to https://crawlertoll-registry-x402test.vgsv-office.workers.dev/.well-known/crawlertoll-keys.json;
  • the payment link check to crawlertoll-registry-x402test.vgsv-office.workers.dev.

Prices

The offer quotes a USDC price. If the publisher sets prices in another currency, the unlock service converts the price to USDC at a daily reference rate. You sign exactly the amount in the offer.

If something goes wrong

Before you sign, a 402 is simply the offer. After you have sent a payment, keep the PAYMENT-SIGNATURE header and the body you sent until you have the key:

  • 200: the key is in cek.
  • 400: final. The service refused the request. Do not retry it.
  • 402: the service could not confirm the payment, for example because the payment network was unreachable. The payment may have gone through.
  • A network error, a 409 (already used) or a 5xx: the key may not have reached you.

In the last two cases, send the identical request again: the same header and the same body. You are not charged twice, because the payment and the replay_secret are the same. That holds within 24 hours, while any pass it bought is still live. If you sign a new payment instead, you pay again.

Don't log the header or the replay_secret: together they can unlock the article again within 24 hours.

Other errors you may meet:

  • 403 from the site's CDN: the publisher blocks bots before the paywall, so your agent never reaches the article. The publisher has to allow agents on sealed articles.
  • Price above your cap: the script stops and signs nothing. Raise the cap only if you accept the price.

AI-training licence

Where a publisher offers it, the AI-training licence is offered to declared AI crawlers: the offer lists it as the tier l0 only when your user agent matches a declared AI crawler. The default user agent of @crawlertoll/sdk, CrawlerToll-Agent, is one since unlock service 2.2.1. Any other user agent sees only the read price.

To buy it, send {"tier_id": "l0"} as the body of the request in step 2, and {"tier_id": "l0", "replay_secret": "0x..."} in step 5. The tier is the offer's licence entry. Sign its amount: its usdc_micros, or its price_micros when the price is already in USD. The response carries the licence as licence.

You can check whether your wallet already holds the licence for an article, before paying, at POST /v1/sealed/:id/licence on https://registry.crawlertoll.com. You prove the wallet address by signing a free message, so nobody else can see what your address has bought.

For the service behind these calls, see the unlock service.