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
| Header | Value |
|---|---|
Link | <payUrl>; rel="payment" |
Retry-After | On 402 handoff_pending only: seconds before the next poll. Same 5 as handoff.retryAfterSeconds. |
payment-required | The x402 terms. Only present when the site also offers x402. |
access-control-expose-headers | Names the headers above so a browser client can read them. |
Body
| Field | Description |
|---|---|
error | payment_required on a first ask. Otherwise a code from Errors. |
priceUsd | Page price in dollars, as a string: "0.001". "0" on a page that asks for human verification and no payment. |
payUrl | Approval page for this handoff, on dna.d3.com. Show it to the person. |
handoff.token | 22 base64url characters, a dot, 43 more. A bearer token for the window. Don't log it. |
handoff.fetchUrl | The requested page with ?d3pay_handoff=<token> appended. Poll it, then fetch it. |
handoff.param | d3pay_handoff, the query parameter above. |
handoff.status | pending, approved, or spent. |
handoff.expiresAt | ISO instant, 24 hours after the refused fetch. The request stops standing then. |
handoff.retryAfterSeconds | Minimum seconds between fetches of fetchUrl while pending: 5. |
how | The steps below as one paragraph, for a model that sees only the body. A site may serve its own wording in other fields instead. |
balanceMicros | With 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
- Give the person
payUrlto open in their own browser. They haven't seen the site's response. Don't sign in or pay for them, and don't fetchpayUrlas the page. - Re-fetch
handoff.fetchUrleveryretryAfterSeconds. Until the person approves, it returns402 handoff_pendingwith the same handoff.- Don't retry the bare page. That mints a new handoff, and the person is asked twice.
- The first response from
fetchUrlthat isn't a402is 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.
| Header | Value |
|---|---|
Set-Cookie | __Host-d3pay=<session>, scoped to the site, valid until handoff.expiresAt. |
Payment-Receipt | Base64 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_requiredwith a fresh handoff, and the flow starts again. - Every fetch of
fetchUrlafter 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-Controlis narrowed toprivate. 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.
| Scope | Covers | Charged | Ends |
|---|---|---|---|
| Site | The whole host, through a session | Each page at its own price | At handoff.expiresAt or when the person ends it. A 402 restarts the flow. |
| Single request | The one URL in handoff.fetchUrl; no cookie, no session | Once | At 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 new402for 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
402carries a new handoff, excepthandoff_pendingandhandoff_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
502is a refusal.
| Status | error | Meaning | Charged | Action |
|---|---|---|---|---|
402 | handoff_pending | Not approved yet, unknown, or expired. | no | Keep polling fetchUrl. Past expiresAt, fetch the bare page. |
402 | handoff_declined | The person declined. Nothing was charged. | no | Stop polling and tell them. If they change their mind, give them payUrl; once they approve, fetchUrl serves the page. |
402 | invalid_handoff | Forged, or minted for another host. | no | Fetch the bare page for a new handoff. |
402 | wrong_page | A single-request grant used on a page it wasn't approved for. | no | Fetch the page it was approved for. |
402 | insufficient_balance | Balance is below the price. balanceMicros says by how much. | no | Tell the person to add credit on payUrl. |
402 | invalid_credential | Session is malformed, unknown, or for another host. | no | Fetch the bare page and start over. |
402 | revoked_session | The person ended this site's access from their account. | no | Fetch the bare page. The person is asked again. |
402 | expired_session | The session's window has passed. | no | Fetch the bare page. The person is asked again. |
502 | payment_unavailable | DNA couldn't settle the payment. The site wasn't reached. | unknown | Retry shortly with the same credential. |