DNAbyD3Docs

Pay with DNA

How an agent handles a `402` that asks a person to approve payment with DNA.

A 402 from a site on DNA means the page has a price and a person must approve it. The agent can't sign in or pay. It shows the person a link, waits for approval, then fetches the page. A whole-site approval mints a session, and later pages are charged against it; a one-page approval covers that page only.

A site may also offer x402 on the same 402, in its payment-required header. Use whichever you support.

The 402 response

An unpaid GET is redirected to DNA's sign-in check, which sends it straight back to the page with its answer attached; the 402 below is what comes back. Any other method, and a browser's own fetch(), get the 402 on the spot.

A top-level navigation, and any client whose Accept names HTML alone, gets the 402 as an HTML page. The JSON below is inside it, whole, in <script type="application/json" id="d3-pay">, so a browser-driving agent reads the same answer it would have been sent. You get JSON instead by naming it in Accept, which holds on any request; failing that, a request that is not a navigation gets JSON unless its Accept names HTML alone or its user agent is a browser's. Follow the 302: the page you return to answers with the body, or with the content once the person has approved.

For some agents DNA answers on the spot instead, with the 402 or with a 200 carrying the same JSON body without error. Handle a body with payUrl and handoff the same way whatever its status. A refused credential always gets the 402.

Headers

HeaderValue
Link<payUrl>; rel="payment"
Retry-AfterOn 402 handoff_pending only: seconds before the next poll. Same 5 as handoff.retryAfterSeconds.
payment-requiredThe x402 terms. Only present when the site also offers x402.
access-control-expose-headersNames the headers above so a browser client can read them.

Body

FieldDescription
errorpayment_required on a first ask. Otherwise a code from Errors.
priceUsdPage price in dollars, as a string: "0.001". "0" on a page that asks for human verification and no payment.
payUrlApproval page for this handoff, on dna.d3.com. Show it to the person.
handoff.token22 base64url characters, a dot, 43 more. A bearer token for the window. Don't log it.
handoff.fetchUrlThe requested page with ?d3pay_handoff=<token> appended. Poll it, then fetch it.
handoff.paramd3pay_handoff, the query parameter above.
handoff.statuspending, approved, or spent.
handoff.expiresAtISO instant, 24 hours after the refused fetch. The request stops standing then.
handoff.retryAfterSecondsMinimum seconds between fetches of fetchUrl while pending: 5.
howThe steps below as one paragraph, for a model that sees only the body. A site may serve its own wording in other fields instead.
balanceMicrosWith insufficient_balance only: the account balance, in millionths of a dollar.
{
  "error": "payment_required",
  "priceUsd": "0.001",
  "payUrl": "https://dna.d3.com/pay?handoff=kQ7mP2vXr9LwZ4tB8nHcYs.aG9uZXN0bHktdGhpcy1pcy1qdXN0LWFuLWV4YW1wbGU",
  "handoff": {
    "token": "kQ7mP2vXr9LwZ4tB8nHcYs.aG9uZXN0bHktdGhpcy1pcy1qdXN0LWFuLWV4YW1wbGU",
    "fetchUrl": "https://example.com/premium?d3pay_handoff=kQ7mP2vXr9LwZ4tB8nHcYs.aG9uZXN0bHktdGhpcy1pcy1qdXN0LWFuLWV4YW1wbGU",
    "param": "d3pay_handoff",
    "status": "pending",
    "expiresAt": "2026-10-14T18:02:11.000Z",
    "retryAfterSeconds": 5
  },
  "how": "This response is meant for software. ..."
}

Handle the 402

  1. Give the person payUrl to open in their own browser. They haven't seen the site's response. Don't sign in or pay for them, and don't fetch payUrl as the page.
  2. Re-fetch handoff.fetchUrl every retryAfterSeconds. Until the person approves, it returns 402 handoff_pending with the same handoff.
    • Don't retry the bare page. That mints a new handoff, and the person is asked twice.
  3. The first response from fetchUrl that isn't a 402 is the page. Read its headers before fetching anything else on the site; they carry the session.

Use the session

The page returned from fetchUrl sets a cookie for the site and carries a receipt. A one-page approval sets no cookie.

HeaderValue
Set-Cookie__Host-d3pay=<session>, scoped to the site, valid until handoff.expiresAt.
Payment-ReceiptBase64 JSON: status (success), reference (the request id), charged and balance (millionths of a dollar; no balance when nothing was charged).

Send the cookie on later requests to the site. A client that keeps no cookies has no session: every other page asks the person again.

  • One session covers one site. Each page is charged at its own price; a page that only asks for human verification is served free, its receipt saying charged: 0.
  • A session from a free verification opens only such pages. A priced page answers 402 payment_required with a fresh handoff, and the flow starts again.
  • Every fetch of fetchUrl after the first is another paid request. Fetch it once, then use the cookie.
  • A handoff in the URL wins over the cookie. A stale cookie never overrides a new approval.
  • On a paid response, the site's Cache-Control is narrowed to private. A read answered from your client's cache isn't charged, and the receipt never enters a shared cache.

Scope

The person chooses the scope when approving.

ScopeCoversChargedEnds
SiteThe whole host, through a sessionEach page at its own priceAt handoff.expiresAt or when the person ends it. A 402 restarts the flow.
Single requestThe one URL in handoff.fetchUrl; no cookie, no sessionOnceAt handoff.expiresAt

With a single-request grant:

  • A retry after a lost response serves the page again without a second charge.
  • Any other page on the site returns wrong_page. A new 402 for that page asks the person again.

A browser check on agent-only pages

A site can price a page for AI agents and let people read it free. On such a page an unidentified request that looks like a browser (a browser user agent, no signature, no payment, Accept allowing HTML) gets the 402 as a page titled "Checking your browser": it runs a short script and, when the browser passes, serves the page with a __Host-d3person cookie that lasts a day for that site. An agent driving a real browser is charged when the signals give it away.

The check needs an enforcement point that can carry it. If your domain points at our nameservers, ours does and there is nothing to do. If you run the adapter yourself, it is the Cloudflare adapter from 0.9, or the Vercel middleware from 0.8. On an older one, or on a recipe you changed yourself, the check is skipped and those visitors read the page free.

If you are an agent, do not run the script. The JSON answer is whole in <script type="application/json" id="d3-pay">, as on every paywall page, and sending Accept: application/json gets you the JSON directly instead of the page. Anything that presents a payment (fetchUrl, the session cookie, an x402 header) is never offered the check.

Errors

  • Every 402 carries a new handoff, except handoff_pending and handoff_declined, which echo the one you sent.
  • Unknown and expired handoffs also return handoff_pending, so the response never reveals whether a handoff exists.
  • A request carrying a credential never fails open. A 502 is a refusal.
StatuserrorMeaningChargedAction
402handoff_pendingNot approved yet, unknown, or expired.noKeep polling fetchUrl. Past expiresAt, fetch the bare page.
402handoff_declinedThe person declined. Nothing was charged.noStop polling and tell them. If they change their mind, give them payUrl; once they approve, fetchUrl serves the page.
402invalid_handoffForged, or minted for another host.noFetch the bare page for a new handoff.
402wrong_pageA single-request grant used on a page it wasn't approved for.noFetch the page it was approved for.
402insufficient_balanceBalance is below the price. balanceMicros says by how much.noTell the person to add credit on payUrl.
402invalid_credentialSession is malformed, unknown, or for another host.noFetch the bare page and start over.
402revoked_sessionThe person ended this site's access from their account.noFetch the bare page. The person is asked again.
402expired_sessionThe session's window has passed.noFetch the bare page. The person is asked again.
502payment_unavailableDNA couldn't settle the payment. The site wasn't reached.unknownRetry shortly with the same credential.

On this page