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 402 can list two methods: d3-pay (this page) and
x402. Use the first one you support.
The 402 response
Only a person's own browser, navigating, is redirected to payUrl. A
recognised agent, or any request carrying a credential, gets the 402.
For some verified agents, DNA answers an unpaid request with a 302 to
payUrl, or 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 |
|---|---|
WWW-Authenticate | Payment id="<resource>", realm="<host>", method="d3-pay", header="Payment-Authorization", request="<base64url>", description="<a sentence>". request decodes to { "priceUsd", "payUrl", "docsUrl" }. |
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. |
preferred, methods | d3-pay, x402, most preferred first. |
priceUsd | Page price in dollars, as a string: "0.001". |
payUrl | Approval page for this handoff, on pay.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",
"preferred": "d3-pay",
"methods": ["d3-pay", "x402"],
"priceUsd": "0.001",
"payUrl": "https://pay.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, or re-send the page withPayment-Authorization: Payment <handoff.token>. 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.
To get these steps as JSON, either:
GET <payUrl>withAccept: application/jsonand notext/html.GET https://pay.d3.com/pay/instructions?handoff=<token>.
The JSON fields are state, site, page, priceUsd, approveBy,
approvalUrl, fetchUrl, scope, instructions (one string), and steps
(the same text as a list). state is ready, approved, taken, expired,
invalid, or unavailable.
Use the session
The page returned from fetchUrl sets a cookie for the site and carries a
receipt.
| Header | Value |
|---|---|
Set-Cookie | __Host-d3pay=<session>, scoped to the site, valid until handoff.expiresAt. |
Payment-Receipt | Base64 JSON: status (success), method (d3-pay), reference (the request id), charged and balance (millionths of a dollar), and session on this first response only. |
Send the cookie on later requests to the site. If your client doesn't keep cookies, send the session token from the receipt:
Payment-Authorization: Payment <session>- A session token is 43 base64url characters. The header value is capped at 128.
- One session covers one site. Each page is charged at its own price.
- Every fetch of
fetchUrlafter the first is another paid request. Fetch it once, then use the cookie. - With more than one credential, the order is: handoff in the URL, then
Payment-Authorization, then 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.
Errors
- Every
402carries a new handoff, excepthandoff_pending, which echoes 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 | 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. |