Protocol Specification
The DVM protocol is a turn-based conversation between a caller and a provider over HTTPS.
The DVM protocol is a turn-based conversation between a caller and a provider over HTTPS. The caller submits a job, the provider does work and sends messages, and control passes back and forth until the job completes. The transport is a REST API with SSE (Server-Sent Events) for streaming.
Future transport bindings (e.g. A2A) may be added later.
Client compatibility
Callers send the same compatibility metadata on every HTTP request:
| Header | Value |
|---|---|
DVM-Client | dvm/<semver>, such as dvm/0.1.0 |
DVM-Protocol-Version | 1 |
DVM-Client-Capabilities | A sorted, comma-separated list of lowercase kebab-case tokens, such as credit-recovery,request-resume |
These headers are support and rollout metadata. They are not authenticated caller identity. A server parses and records valid client versions, protocol versions, and capability sets as structured request fields. Every server response carries DVM-Protocol-Version: 1.
Compatibility is additive and endpoint-specific. Missing headers remain valid on an endpoint that declares no requirement. A future protocol version is recorded but is not rejected by itself. An endpoint may require one or more capability tokens, a minimum dvm version, or both. The check runs before that endpoint's handler and its side effects.
When the caller cannot meet an endpoint's declaration, the server returns HTTP 426:
{
"error": "client_upgrade_required",
"required_capabilities": ["credit-recovery", "request-resume"],
"minimum_client_version": "0.1.0",
"current_client_version": "0.0.9",
"display": "This request needs a newer dvm client.",
"hint": "Upgrade or reinstall dvm with support for capabilities credit-recovery, request-resume and dvm 0.1.0 or newer, then retry this request."
}
required_capabilities is always present and sorted. minimum_client_version appears only when the endpoint declares one. current_client_version appears only when DVM-Client contains a valid dvm/<semver> value. A missing or malformed client version cannot satisfy a declared minimum. A missing, malformed, duplicated, or unsorted capability list satisfies no required capabilities.
Capability tokens are additive. Document a new token with the endpoint behavior that introduces it. Roll out a requirement in four stages: expand the server first, release and migrate the CLI, observe adoption, then contract the old behavior. A production endpoint must not require a capability or version that exists only in an unreleased CLI.
Conversation model
A job is a conversation. The provider and caller take turns. Each turn ends when the provider yields — sends a message that hands control to the caller.
Yield types
Five message types end the provider's turn:
| Yield message | What it means | Caller's next action |
|---|---|---|
prompt | Provider needs input | Send a response message |
payment-request | Provider needs payment | Send a payment message |
working | Provider is busy, will take a while | Disconnect, check back later |
complete | Job finished | Done |
cancel | Job failed | Done |
complete and cancel are terminal. The conversation is over. The other three are non-terminal yields where the provider expects the caller to act before work continues.
Non-yielding messages
text, progress, and artifact messages stream to the caller without ending the provider's turn. A provider can send any number of these before yielding. Use them for status text, structured progress heartbeats, intermediate results, and artifacts that precede the final complete.
Turn flow
Caller submits job
|
v
Provider's turn
|
|-- text: "Working on it..." (non-yielding, streams to caller)
|-- artifact: { data: "...", ... } (non-yielding)
|-- prompt: "Which option?" (YIELD — provider's turn ends)
|
v
Caller's turn
|
|-- response: "Option B" (caller sends, provider's turn resumes)
|
v
Provider's turn
|
|-- text: "Refining option B..." (non-yielding)
|-- complete: "Done" (YIELD, terminal)
A yield closes the SSE stream. The caller reconnects (opens a new stream) to resume the conversation after acting on the yield.
When the provider needs a long time
If the provider has a long stretch of work ahead (minutes to hours), it yields with a working message rather than holding the connection open:
Provider: text "Got it, I'll design three concepts."
Provider: working { estimate_seconds: 1800, hint: "Check back in ~30 min" }
(connection closes)
The caller disconnects and checks back later. The provider continues working and persists messages to its store. When the caller reconnects (with ?after=<last_seq>), it picks up where it left off.
Messages
Every message has these base fields:
| Field | Type | Description |
|---|---|---|
seq | number | Monotonic sequence number |
from | "requester" or "provider" | Which side sent the message |
timestamp | number | Unix timestamp (seconds) |
type | string | Message type (see below) |
content | object | Type-specific payload |
The wire value for the caller's side is "requester". This spec calls that party the caller throughout its prose; "requester" is the same role's name on the wire.
Provider to client
text
Free-form status or progress text. Non-yielding.
{ "type": "text", "content": { "text": "Fetching page content..." } }
| Field | Type | Required | Description |
|---|---|---|---|
text | string | yes | Message text |
prompt
Request input from the caller. Yields.
{
"type": "prompt",
"content": {
"id": "p1",
"text": "Which concept do you prefer?",
"options": ["A — Modern", "B — Classic", "C — Minimal"]
}
}
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique prompt ID (for matching responses) |
text | string | yes | Human-readable question |
options | string[] | no | Simple choice list |
schema | object | no | JSON Schema for structured responses |
When schema is present, the caller should respond with structured data conforming to the schema. The text field remains the human-readable question — shown to humans and gives LLM agents context for what's being asked.
{
"type": "prompt",
"content": {
"id": "p2",
"text": "Provide your brand assets and preferences",
"schema": {
"type": "object",
"properties": {
"primary_color": { "type": "string", "description": "Hex color code" },
"tone": { "type": "string", "enum": ["professional", "playful", "bold"] }
},
"required": ["primary_color"]
}
}
}
artifact
Job output — inline data or a URL reference. Non-yielding (send before complete).
{
"type": "artifact",
"content": {
"data": "# Extracted Content\n\nThe page says...",
"mime_type": "text/markdown",
"name": "extracted-content.md"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | Human-readable name |
mime_type | string | no | MIME type |
data | string | no | Inline data (text or base64) |
encoding | string | no | "utf-8" or "base64" for inline data |
url | string | no | URL to fetch |
size_bytes | number | no | Size in bytes (for URL-referenced artifacts) |
sha256 | string | no | SHA-256 hash for integrity verification |
Either data (inline) or url (by reference) should be present. For small outputs (<256 KB), inline is simpler. For large files, use a URL.
payment-request
Request payment to continue. Yields.
{
"type": "payment-request",
"content": {
"amount_msats": 50000,
"reason": "Design exploration: 3 concept directions",
"mints": ["https://mint.example"]
}
}
| Field | Type | Required | Description |
|---|---|---|---|
amount_msats | number | yes | Amount requested in millisatoshis |
reason | string | yes | Human-readable explanation |
mints | string[] | no | Cashu mint URLs accepted |
min_locktime_seconds | number | no | Minimum P2PK locktime required |
fiat_amount | object | no | { amount, currency } — the operator's fiat-denominated intent when this charge was priced in fiat. Absent when priced in sats directly |
lock_pubkey | string | no | NUT-11 P2PK lock pubkey to lock Cashu outputs to. Falls back to /v1/info's cashu.lock_pubkeys[0] when absent |
x402 | object | no | The x402 stablecoin option — see below |
tempo | object | no | { challenges: [...] } — the Tempo option, see below |
x402 carries { pay_to, network, asset, facilitator?, required_usdc_micro?, payment_required?, nonce? }. payment_required is the full v2 challenge (a PAYMENT-REQUIRED envelope, same shape as the 402 challenge); its absence selects legacy v1. nonce, when present, is the server-issued EIP-3009 bytes32 nonce the caller must sign into TransferWithAuthorization for this payment-request — it binds the credential to this job. Reply with content.x402_payment on the payment message (see below).
tempo carries { challenges: [...] } — one challenge per method/intent this DVM accepts for a mid-job Tempo payment. Pick the entry matching your wallet's method and construct a Credential against it; reply with content.tempo_credential.
working
Signal that the provider is busy and will take a while. Yields. The caller should disconnect and check back later.
{
"type": "working",
"content": { "estimate_seconds": 1800, "hint": "Generating designs, check back in ~30 min" }
}
| Field | Type | Required | Description |
|---|---|---|---|
estimate_seconds | number | no | Rough time estimate |
hint | string | no | Human-readable hint |
progress
A structured progress heartbeat. Non-yielding — unlike working, the SSE stream stays open across heartbeats so the caller can render live progress without reconnecting.
{
"type": "progress",
"content": { "percent_complete": 40, "current_phase": "transcribing", "hint": "12 of 30 minutes" }
}
| Field | Type | Required | Description |
|---|---|---|---|
percent_complete | number | yes | Estimated completion, 0–100 |
current_phase | string | no | Coarse phase label (DVM-defined, e.g. transcribing) |
hint | string | no | Human-readable hint about what's happening now |
complete
Job finished. Terminal yield.
{
"type": "complete",
"content": {
"summary": "Landing page design completed"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
summary | string | no | Human-readable summary |
After complete, no more messages should be sent.
cancel (provider-initiated)
Job failed. Terminal yield.
{ "type": "cancel", "content": { "reason": "Invalid URL: expected http:// or https://" } }
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | no | Error message |
Client to provider
response
Caller's answer to a prompt.
{
"type": "response",
"content": {
"prompt_id": "p2",
"text": "Here are my brand details",
"data": { "primary_color": "#1B3A5C", "tone": "professional" }
}
}
| Field | Type | Required | Description |
|---|---|---|---|
prompt_id | string | yes | Matches the prompt's id field |
text | string | yes | Free-form text answer |
data | object | no | Structured data conforming to the prompt's schema |
When the prompt included a schema, data should be present and conform to it. If validation fails, the provider can re-prompt with the same schema and explain what was wrong.
payment
Caller sends payment proof.
{
"type": "payment",
"content": {
"cashu_token": "cashuAeyJ...",
"mint": "https://mint.example",
"amount_msats": 50000
}
}
| Field | Type | Required | Description |
|---|---|---|---|
cashu_token | string | no | Cashu ecash token |
mint | string | no | Mint URL |
amount_msats | number | no | Amount in millisatoshis |
cashu_request_id | string | no | UUIDv4 per-call id for the agent-wallet accumulator path — the mid-job equivalent of the upfront flow's X-Cashu-Request-Id header |
x402_payment | string | no | Base64-encoded x402 PaymentPayload — the mid-job equivalent of the upfront PAYMENT-SIGNATURE (v2) or X-PAYMENT (v1) header |
tempo_credential | object | no | Structured Tempo Credential ({ challenge, payload, source? }) answering a payment-request's tempo.challenges entry |
reason | string | no | Only for provider-sent: "change" or "refund" |
The payment type can also flow provider to caller with reason: "change" or "refund" for overpayment change or refunds.
approval
Caller approves a proposed action.
{ "type": "approval", "content": { "ref_seq": 7, "comment": "Looks good, proceed" } }
| Field | Type | Required | Description |
|---|---|---|---|
ref_seq | number | no | Seq of the message being approved |
comment | string | no | Optional comment |
cancel (client-initiated)
Caller cancels the job.
{ "type": "cancel", "content": { "reason": "No longer needed" } }
Provider text provenance in CLI output
The dvm CLI wraps provider-supplied free-text fields (names, descriptions, prompt text, payment reasons, etc.) as { "_source": "provider", "text": "..." } in its JSON output. This is a CLI output convention, not a wire-format change — messages on the wire use plain strings as documented above. See Provider text provenance for the full field list and a worked example.
Payment
Upfront payment
When a provider requires payment before starting work, the caller attaches an X-Cashu header to POST /v1/job. The server verifies the token, checks the amount, and returns 402 if missing or insufficient. Overpayment is kept and nothing is returned on the response: the full amount presented funds the caller's credit, and an unsigned request then draws all of it for the job, so a caller who sends more than the price is charged what they sent. A signed request carrying a credit envelope draws only the price and leaves the excess as available balance.
Mid-job payment
The provider sends a payment-request message (a yield) specifying the amount and reason. Work pauses until the caller sends a payment message. This supports incremental billing for multi-step jobs.
Mid-job rail envelopes ride the payment message's body, not request headers — the SSE message channel is the transport, so the per-rail credential goes inside content. The mid-job equivalent of the upfront X-Cashu header is content.cashu_token (+ content.cashu_request_id for the agent-wallet accumulator); the upfront x402 header — PAYMENT-SIGNATURE (v2, the default generation) or X-PAYMENT (v1) — becomes content.x402_payment (base64-encoded PaymentPayload); and the upfront Authorization: Payment … (Tempo) becomes content.tempo_credential (a structured Credential.Credential).
Cashu
The primary payment method. Callers send Cashu ecash tokens (cashu_token field in payment messages). The provider specifies accepted mints in its info endpoint and in payment-request messages.
The value a provider accepts from a Cashu token is the decoded proofs' gross face minus the standalone NUT-02 input fee for that exact proof set. Each proof is priced against its own loaded keyset, including mixed or inactive keysets. A conformant caller uses includeFees(true) when creating the provider-locked token, so the extra face reserved for the provider's eventual spend makes the net value equal the advertised amount; that reserve is processing-fee allowance, not caller principal. Payment gates, paidMsats, prepaid balance, refunds, and draw revenue all use the net value, while the accumulator and deposit reporting retain gross face and the fee allocation for reconciliation.
Prepaid credit
A credit is a per-provider prepaid balance belonging to one authenticated caller: fund it once, draw from it across many jobs. It amortises payment overhead over N jobs and converts "the wallet must be reachable when the job runs" into "the wallet must be reachable sometime this funding window."
Per-call payment is unchanged and remains the universal floor. A request that attaches a payment and names no credit is an implicit N=1 funding: the provider funds a single-use credit and draws it in the same request, so the legacy request shape stays valid byte-for-byte.
Per-call payment and prepaid credit are the two customer payment modes. Rail-native values such as exact, batch-settlement, charge, session, and channel are instruments underneath them: one-shot stablecoin authorizations fund per-call payment, while reusable stablecoin channels fund prepaid credit by default. A builder may explicitly admit one-shot stablecoin authorizations to prepaid credit and accept the resulting manual refund obligation; the channel is custody and settlement underneath credit rather than a third mode.
Credit is opt-in per provider. A provider that offers it advertises a credit block on /v1/info, /v1/quote, and every 402. A provider that doesn't offer it omits the block entirely and answers /v1/credit with credit_not_supported. Amounts are always micro-units: 1e-6 of the block's currency, never sats or msats.
Credit requires the caller to be authenticated (a credit belongs to a verified pubkey), so only providers declaring a signed-request auth scheme advertise it. Every first-party container DVM (cast, narrate, scrape, scribe) does.
The funding menu
The sizing and funding terms are present on /v1/info, /v1/quote, and 402 bodies from /v1/job:
{
"credit": {
"min_micro": 100000,
"max_micro": 5000000,
"ttl_ms": 2592000000,
"currency": "usd",
"funding": ["cashu", "x402", "lightning"],
"lightning_min_micro": 2300000,
"x402": {
"schemes": [
{ "scheme": "batch-settlement", "network": "eip155:84532" }
]
},
"credit_id": "b1f0…",
"balance_micro": 750000,
"remaining_micro": 620000,
"expiry_ms": 1795200000000
}
}
| Field | Type | Description |
|---|---|---|
min_micro | number | Smallest funding accepted |
max_micro | number | Ceiling on the caller's residual balance, not a funding amount (see below) |
ttl_ms | number | Credit lifetime, measured from the most recent funding |
currency | string | Denomination of every *_micro field here and of the resulting credit |
funding | string[] | Rails a top-up may arrive on. lightning is funding-only (see below) |
lightning_min_micro | number | Optional. The lightning rail's own minimum, above min_micro (see below) |
tempo | object | Optional. { methods: [{ method, intent }], withheld? }: Tempo funding methods, see below |
x402 | object | Optional. { schemes: [{ scheme, network }] }: which x402 flavours fund a credit, see below |
credit_id | string | Echo only. Which credit the balance fields describe |
balance_micro | number | Echo only. Funded balance, pending holds not subtracted |
remaining_micro | number | Echo only. Spendable balance: what a draw is checked against |
expiry_ms | number | Echo only. When the echoed credit stops accepting new draws |
The four echo fields never appear on /v1/info, which is unauthenticated and cacheable. They appear on /v1/quote and 402 responses only when the request carried a verified caller pubkey and that caller holds a live credit in currency, so a single quote answers both "what does this cost?" and "what do I have left?". A caller holding several credits gets the one with the largest spendable balance.
currency is a per-provider constant: the currency the provider prices in, identical on /v1/info, /v1/quote, every 402, and POST /v1/credit, and the same one the quote's upfront is denominated in. A caller can therefore compare min_micro / max_micro / remaining_micro against upfront with no conversion, and can top up against terms learned from any discovery or payment surface knowing the credit it mints is the one their next job will draw from. A provider that changes what it prices in changes this field. An existing balance in the old denomination stays the caller's and stays visible on the balance op, but no longer pays for jobs; the drain op is how it comes back.
max_micro binds the residual balance, not the funding amount. A job priced above max_micro still clears, because funding and drawing happen in the same request and the residual delta is zero. The ceiling is enforced only on POST /v1/credit, where a top-up leaves money sitting. It applies to the caller's total spendable balance at this provider, summed across every credit they hold in currency, so opening extra credits doesn't raise it. Expired credits don't count against it.
Minimums are per-rail (DVM-1519). min_micro is the menu-wide floor; a rail may carry a higher one of its own, stated as a <rail>_min_micro sibling. Today only lightning_min_micro exists: a Lightning deployment's receive channel has a physical routing floor, and an invoice below it fails every payment attempt. The advertised figure carries a safety margin over the raw floor (the floor is sats-physical, the menu is fiat, and rates drift), so funding exactly it always clears; a funding below it is refused with below_rail_minimum naming the rail, the minimum, and the alternatives that carry any amount. A rail with no entry has no floor beyond min_micro. The field is absent from providers that predate it: treat absence as "no per-rail floor stated", not zero.
tempo and x402 name their funding mechanics. Both can fund prepaid credit through a durable channel, so their entry in funding is not on its own enough to fund against: the caller has to know what the provider will accept. Each carries a sub-block, and they answer different questions without creating additional customer payment modes.
tempo.methods lists the Tempo methods that can fund the credit. A session opens or reuses a durable channel; a charge makes one ordinary payment into the durable ledger and needs no channel close watcher. Providers admit session by default. charge appears only when the builder explicitly accepts one-payment stablecoin credit and its manual refund obligation. tempo appears in funding only when at least one admitted method is currently offered — methods non-empty — not merely admitted. A session-only provider whose observer is observer_unhealthy withholds its one method entirely, so tempo drops out of funding even though tempo.withheld still lists it as the capability signal described below. When both Tempo intents are present, callers prefer session to amortize later top-ups. They fall back to charge only when session funding is withheld and the credit isn't already bound to a channel: a channel-bound credit keeps using session even while withheld, per the exception below, since a one-shot charge against it is refused.
tempo.withheld names a method the provider is configured for and is not offering at this instant, each with a reason. observer_unhealthy means the chain watcher that protects a session channel is not confirmed running; settlement_unhealthy means the hosted provider's public operator account is below its configured fee-token reserve or a settlement transaction was rejected for insufficient fee funds. It is advisory: attempt only what methods lists. Its value is that a withheld method is a temporary absence, so a caller can retry later rather than concluding the provider never offered it, and can say as much to whoever is waiting. The provider refuses a withheld instrument at issuance and at acceptance too, so presenting a session credential for a channel it has never seen earns a refusal rather than a channel.
One exception, and it is the case that matters most: a credit already bound to a Tempo session channel may still be topped up over tempo/session while it is withheld. Both health reasons pause new channels. An existing channel's deposit is already exposed, and a channel-bound credit can be funded no other way, since a one-shot charge against it is refused. So a caller holding an open channel should send its POST /v1/credit regardless of the withholding: the provider issues that credit a session challenge, and the acceptance check keys on the channel the credential names rather than on the credit. The menu itself cannot say this: it is one advertisement for every caller, and the exception is per-credit.
x402.schemes lists which x402 flavours a top-up may arrive on, each with the CAIP-2 chain it rides. batch-settlement is the default credit instrument: a reusable channel a voucher is signed against. exact joins it only when the builder explicitly accepts one-payment stablecoin credit and its manual refund obligation. An exact-only deployment therefore omits x402 from credit.funding while keeping x402 exact under /v1/info for per-call payment. Read the scheme and the network before choosing; treat a rail listed with no sub-block (an older provider) as offering neither.
This is a prepaid-credit policy, not a stablecoin payment switch. Tempo charge and x402 exact remain available per call whenever their rail is configured. Existing charge- or exact-funded credits remain readable, drawable and drainable after a provider adopts the reusable-only default, and a retry of a funding the provider already committed still returns its recorded outcome. Only a new one-payment top-up is refused.
cashu and lightning carry no such block: funding over them needs no prior instrument.
The menu degrades rather than failing: a rail whose backing wallet is unreachable drops out of funding, and a provider that can't state its bounds in the quote's currency omits the whole block. A provider that can't currently render a rail's minimum drops that rail rather than advertising it unstated. An absent block means "no credit here", never "credit is broken".
Drawing against a credit
A job draws by carrying two fields inside the signed request body on POST /v1/job:
| Field | Type | Required | Description |
|---|---|---|---|
credit_id | string | yes | The credit to draw from |
draw_id | string | yes | Caller-generated; the provider's idempotency key for this draw |
fund | object | no | { amount_micro, commitment } when a payment rides the same request |
draw_id is what makes a lost response safe: re-submitting the identical request with the same draw_id returns the original job and never debits twice. Reusing a draw_id for a different request is refused with draw_conflict.
fund.commitment is the SHA-256 hex of the raw payment artifact attached on the header. It binds the header-borne payment (which sits outside the signed body) to the credit its sender intended.
fund.amount_micro is the provider's valuation of that artifact, and the provider recomputes it rather than trusting it: price_micro × paid_msats / required_msats, rounded half-up, where paid_msats is the artifact's own value and an exact payment returns price_micro unrounded. Both other inputs must be provider-authored, and both are stated as exact integers on the 402 being answered: price_micro beside required_msats, resolved against one fx snapshot. Take them from there. /v1/quote's upfront.amount_micro is the same figure for a caller that quoted first; a figure derived from a caller-side rate, or reconstructed by multiplying a rendered decimal price by a million, will not match. On x402 the same ratio is taken in the rail's own units instead of millisatoshis, for the reason given below.
A caller that can't reproduce it exactly is not stuck, but what to do about it depends on the rail. Every amount mismatch is refused with funding_commitment_mismatch carrying expected_micro: the provider's own valuation of the artifact attached. Whether that figure is a retry instruction or a reconciliation record depends on where the rail's amount check sits relative to its commit.
On Cashu the check runs after the provider has decoded the token and valued its proof-set fee, but before any proof or ledger row is committed, so nothing was captured: re-sign the same token with fund.amount_micro set to expected_micro and resubmit. The commitment doesn't change, because the artifact didn't. expected_micro values the token's net principal, not its gross fee-reserved face. A caller signing off price_micro should not need this: the exact figure and required_msats come off one envelope, so a provider that prices in sats is as reproducible as one that prices in dollars. What survives is the window the wire cannot close: the provider re-resolves both figures when the payment arrives, so a rate that rotates between the 402 and the submit moves them, and this is the corrective path back.
On x402 and tempo the check runs only after the facilitator has settled or the credential has been consumed, so the refusal is fail-closed: the money moved, no job ran, and no credit was minted. expected_micro is a reconciliation figure there, not a retry instruction: the credential is spent, re-presenting it cannot land, and only the provider's operator can reconcile it from the settlement reference in its log. How the valuation is reached differs between the two, though. tempo compares against the quoted fiat price directly, so fund.amount_micro is that price in micro-units unmodified and the only way to mismatch is to sign something else. x402 values the settled authorization in the rail's own units, against the maxAmountRequired the 402 advertised: sign price_micro unmodified when the authorization's value equals maxAmountRequired, and price_micro × value / maxAmountRequired rounded half-up when it deliberately pays more. Millisatoshis never enter that valuation. Deriving them back from the settled USDC is not the inverse of the conversion that produced maxAmountRequired, so it used to read high and value an exact payment above the quoted price. Nor do they enter the advertisement: on a USD-priced DVM maxAmountRequired is price_micro, because USDC is a six-decimal USD stablecoin and the price is already an exact USD micro figure. No rate takes part, so it is the same number on the 402 and at the submit whatever the provider's fx cache did in between. Both inputs therefore come off the 402 alone, and a payment of exactly the advertised amount lands, full stop. x402 is offered only by USD-priced DVMs. A DVM priced in a currency other than USD omits x402 from discovery and from HTTP 402 responses. It does not derive a USDC amount through an exchange rate.
Re-sending after a commitment mismatch is pointless on every rail: the artifact and the hash simply disagree. That's why expected_micro is present only on the amount branch.
No debit on job failure: a draw is a pending hold at accept time, settled on success and released on failure or cancellation.
POST /v1/credit
Signed-request envelope and replay protection, same as /v1/job. The operation is named inside the signed body, so it can't be rewritten in flight. Payment artifacts ride the same headers as /v1/job (X-Cashu + X-Cashu-Request-Id; PAYMENT-SIGNATURE (x402 v2, default) or X-PAYMENT (x402 v1); or Authorization: Payment …) and failures return the same 402 envelope.
{
"op": "fund",
"credit_id": "b1f0…",
"fund": { "amount_micro": 2000000, "fund_id": "9c22…", "commitment": "<sha256 hex>" },
"pubkey": "…",
"signature": "…",
"timestamp": 1790000000,
"nonce": "…"
}
| Op | Purpose |
|---|---|
fund | Top up with no job attached. Omit credit_id to open a new credit |
balance | Read the caller's credits. Omit credit_id for all of them |
drain | Reclaim the unspent balance. Request, poll, and pickup are all this one op (see drain) |
fund_id is the caller-generated idempotency key for a top-up, exactly as draw_id is for a draw: repeating a top-up with the same fund_id credits once and returns the original outcome with "replayed": true. This holds for a first top-up too: when credit_id is omitted the provider derives the new credit's id deterministically from the caller's pubkey and fund_id, so a retry after a lost response lands on the same credit instead of opening a second one and re-presenting a spent artifact.
fund.commitment works exactly as it does on a job draw, and is checked before any payment rail is touched: a top-up whose attached artifact doesn't hash to the signed commitment is refused with funding_commitment_mismatch and nothing is captured.
Tempo top-ups also sign their intended instrument before a credential exists: fund: { amount_micro, fund_id, commitment, method: "tempo", intent: "charge" | "session" }. The same fields stay on the paid retry. This lets the provider refuse a charge against a session-bound credit, or a session against a charge-funded credit, on the unpaid discovery request rather than after the credential broadcasts.
A new Tempo charge or x402 exact top-up at a reusable-only provider is refused with one_shot_credit_not_supported before a credential settles or the ledger changes. The structured copy says that reusable channels still fund prepaid credit, the one-payment instrument still pays per call, and nothing moved. A provider that previously advertised only Tempo charge or x402 exact loses that stablecoin rail from credit.funding under the new default; its per-call advertisement and acceptance do not change.
That credit-policy conflict returns 409 from POST /v1/credit. one_shot_credit_not_supported is credit-only and is never returned by POST /v1/job.
fund.amount_micro does not work as it does on a job draw: here it is the amount being asked for, not a valuation to be reproduced, so there is no equality to fail and this route never answers funding_commitment_mismatch over an amount. An over- or under-payment credits pro rata instead (see fund below). A funding_commitment_mismatch on /v1/credit is therefore always the commitment branch, and re-sending the same body cannot fix it.
One credit, one funding instrument. A credit's balance carries the rail-native value that funded it, and two rails' native units don't sum, so topping up a credit over a rail other than the one that opened it is refused with payment_invalid before the payment, with credit_rail naming the one it does take. Reusable channels add an identity inside their rail: a Tempo charge and Tempo session are distinct instruments, as are exact x402 and an x402 batch-settlement channel. A Tempo session top-up must return to the channel stored on its target credit; an x402 batch-settlement channel additionally cannot fund two credits. Instrument refusals also carry credit_instrument, credit_channel_id when applicable, and bound_credit_id when another credit owns an x402 channel. They are terminal responses, not corrective 402s: top up through the named instrument and channel, or omit credit_id to open a separate credit.
Funding over Lightning
lightning appears in funding when the provider can issue its own invoices. It is a funding rail only: no request ever attaches a bolt11, and lightning never appears in a quote's payment.methods.
Lightning has no artifact to be recognised by, so it names itself differently from the attach rails: send fund: { amount_micro, fund_id, method: "lightning" } and omit commitment (sending one is refused). There is nothing to commit to: the provider mints the invoice for the credit_id, fund_id and amount in the signed body, which is the same binding by other means.
The leg is interactive and takes two round trips:
-
The first request returns
402 lightning_settlement_pendingcarrying the invoice to pay.{ "error": "lightning_settlement_pending", "credit_id": "fnd:35ba…", "fund_id": "9c22…", "lightning": { "invoice": "lnbc…", "payment_hash": "…", "amount_msats": 300000, "expires_at_ms": 1790000900000 } } -
Pay it, then re-send the same request (same
fund_id) to be credited. Any other authenticated request works too: abalanceread, or a job submit naming the credit. Providers check for settlement on caller contact rather than watching for it, so the balance appears on the next thing you do, not on a timer.
Re-polling before payment returns the same invoice, never a new one; a provider that issued a second would leave two payable invoices against a top-up that can only be credited once. Polling after crediting returns the ordinary "replayed": true fund response.
An invoice belongs to the caller who asked for it. Naming a credit_id + fund_id pair someone else is already funding is refused with credit_id_unavailable: never answered with their invoice, which anyone could then pay onto a balance that isn't theirs.
A funding below the provider's lightning_min_micro is refused with 400 below_rail_minimum before any invoice is issued: a sub-floor invoice could never route, so refusing is the honest answer. The body carries rail, min_micro (the advertised minimum that always clears), alternatives (the rails that carry any amount), and currency. Resize to the minimum or fund over an alternative rail; re-sending the same amount can never succeed. An invoice that was already issued is never re-gated: re-polls and settlement work unchanged if the provider raises its floor while one is outstanding.
An invoice that expires unpaid is refused with lightning_invoice_expired and has no effect on any balance; start again with a fresh fund_id. A provider whose wallet is unreachable drops lightning from funding and answers lightning_unavailable: the other rails in funding still work.
Reuse a fund_id across rails and one of the two payments has nowhere to land: (credit_id, fund_id) keys a top-up exactly once, so if an attach-rail payment claims the pair after the invoice was minted, an invoice paid afterwards can never be credited. The provider answers credit_funding_unrecorded: do not pay again, and do not treat it as pending. Only its operator can reconcile from there. Keep polling the same fund_id: if the operator does credit the payment, that poll turns into a 200 carrying reconciled: true and a credit block naming the credit the balance landed on, which will not be the credit_id you asked for. Use a fresh fund_id per top-up and the case cannot arise.
Credit ids beginning imp: or fnd: are derived by the provider: the first for a per-job payment, the second for a top-up that named no credit. A caller may name one it already holds, which is how later top-ups stack onto it and how a job draws from it. Naming one it doesn't hold is refused with credit_id_unavailable on both /v1/credit and /v1/job, and the refusal is identical whether or not a credit exists at that id. Both halves of that follow from a derived id being computable from public inputs (imp: from the rail's payment id, fnd: from the caller's pubkey and fund_id): if a caller could open one, anyone could pre-open the id a provider will later derive for someone else, whose next top-up would then settle on the rail and be refused as belonging to another caller. And if the refusal varied with existence, the namespace could be walked to discover which credits a provider holds.
A caller-chosen credit_id is claimed by whoever funds it first and refused to everyone else, so pick one nobody else would guess: a UUID, not acme-prod.
fund → 200. funded_micro is what was actually credited, which is the requested amount_micro for the exact payment every conformant caller sends; an over- or under-payment credits pro rata at the quoted rate, so compare the two if the rail can pay approximately. For Cashu this is the token's net principal after its exact NUT-02 proof-set fee, even though the provider physically accumulates the larger gross token created by includeFees(true).
funding_receipt is signed under the same receipt key the builder attests on /v1/info. It fixes the credit, funding id, credited amount, funding-time balance and ledger sequence, issuance time, and caller pubkey. A retry returns the original persisted artifact byte-for-byte even when later draws changed the live credit projection. An explicit fund-and-draw on /v1/job carries the same funded_micro, credit, and funding_receipt fields on its job response.
{
"op": "fund",
"funded_micro": 2000000,
"replayed": false,
"funding_receipt": {
"v": 1,
"kind": "credit_funding",
"credit_id": "b1f0…",
"fund_id": "5c77…",
"funded_micro": 2000000,
"balance_after_micro": 2620000,
"ledger_seq": 4,
"issued_at": 1790000000,
"caller_pubkey": "02ab…",
"dvm_id": "dvm_01…",
"dvm": "example",
"receipt_pubkey": "03cd…",
"signature": "a9f1…"
},
"credit": {
"credit_id": "b1f0…",
"currency": "usd",
"balance_micro": 2750000,
"remaining_micro": 2620000,
"expiry_ms": 1795200000000,
"expired": false,
"ledger_seq": 4
}
}
balance → 200 with credits[] (every credit the caller holds, including expired ones: expiry ends spending, not ownership) and credit (the one a draw would use, absent when none qualifies). Every credit projection carries drainable: true whenever unspent value remains to reclaim, expired or not.
The response also carries menu: the same funding menu block /v1/quote and every 402 advertise, so one read answers both "what do I hold?" and "what may I add?". It rides under menu rather than credit because on this endpoint credit is the credit projection, and it omits the credit_id / balance_micro / remaining_micro / expiry_ms echo, which credits[] already states in more detail. Treat its absence as "this provider said nothing" (an older provider), never as "this provider has no menu".
drain reclaims the unspent balance (expiry ends spending, never ownership: an expired credit drains identically to a live one). The signed body names the credit, a caller-generated drain_id, and, on the first request, the payout rail and destination:
{
"op": "drain",
"credit_id": "b1f0…",
"drain": {
"drain_id": "5c77…",
"method": "cashu",
"payout": { "refund_pubkey": "02ab…" }
},
"pubkey": "…",
"signature": "…",
"timestamp": 1789000000,
"nonce": "…"
}
Per-method payout targets:
cashu→refund_pubkey: a 33-byte compressed secp256k1 key the refund notes are P2PK-locked to (your Cashu wallet's lock key, not the x-only request-signing key).x402/tempo→address(0x-prefixed EVM address).
A credit funded through a channel (an x402 batch-settlement channel, or a Tempo session channel) is bound to that channel's rail: method must be x402 / tempo respectively, and any other is refused drain_conflict. The destination is the channel's own payer rather than anything the caller names, so tempo may omit the payout block entirely, and a channel-bound x402 drain does not read it.
A credit funded by a one-off stablecoin payment (an x402 exact authorization, a Tempo charge) has no channel, so address is required on both rails: a drain that omits it is refused invalid_request (400) naming the field and the rails that do work.
The two stablecoin rails are not interchangeable either. An x402 or tempo payout on a credit funded over a different rail is refused invalid_request (400), since an address on a chain the money never came from is a liability nobody can pay. cashu reclaims a one-payment credit's balance the ordinary way, and is also how a Lightning-funded credit comes back: lightning is a funding rail only, never a payout one, so naming it as a drain method is refused drain_method_unsupported (400) before anything is reserved.
The first accepted request debits the credit's entire available balance (in-flight job holds stay put; when they release, the remainder is reclaimed with a fresh drain_id) into a drain liability and answers status: "pending". The channel rails are the exception: they settle inside the request instead (below). Re-sending the same drain_id (with or without the drain.method/payout block) is the poll/pickup: it never debits twice, and it returns the drain's current state until (and after) the refund lands:
pending: approved, not yet fulfilled. Cashu refunds are prepared by the provider's batch job (a down mint delays parking; the money stays owed). A one-payment stablecoin drain (an x402exactauthorization or a Tempo charge) has no automated sender at all on either rail, and is settled by the provider's operator by hand.parked/picked_up(cashu): the response carriestoken, notes P2PK-locked to yourrefund_pubkey, redeemable at the mint. Polling again returns the same token; only your key can claim it.sent(x402/tempo): the response carriessentwith the settlement reference (e.g. the on-chain transaction hash). On the channel rails this is the first answer, not a later one.released(tempo, channel-bound only): the channel could not complete this cooperative refund, so the service restored the amount to your prepaid balance instead of paying it out. Terminal: rundvm wallet tempo-exit <dvm>to recover the channel on-chain, and don't poll thisdrain_idagain.
Credits funded through reusable stablecoin channels drain cooperatively, in the request. A drain on one is answered 402 carrying the channel's own challenge (batch-settlement refund requirements on x402, a session-close challenge on tempo), and the resubmit attaching the caller's signed refund voucher (PAYMENT-SIGNATURE) or close credential (Authorization: Payment) settles on-chain and answers sent with the settlement evidence. Nothing is queued, no provider spending key takes part, and the money lands at the payer the channel was opened from. Every in-flight draw on the credit must have finished first, which drain_conflict says explicitly. The payout figure is the provider's to derive from its own ledger rather than the payer's to choose: an x402 refund voucher naming an amount is refused outright, and the tempo close reconciles against chain state rather than against the request. Because the provider's claim job never claims past what the credit's settled draws have earned, the unspent remainder is still in escrow whenever the caller asks for it, including when the provider has stopped answering. That is exactly what the caller's own timed on-chain withdrawal reaches, with no cooperation at all.
A cooperative x402 refund can return a reconciliation error when the provider cannot establish a safe outcome. settlement_not_on_chain means a chain scan found no matching refund and the attempt is back in a safe retry state, so resend the same drain_id; chain_unreachable means the provider cannot read the chain, and settlement_unbookmarked means it has no submission checkpoint to scan. Neither of those says that the payment failed, so do not re-sign repeatedly or create a new drain while the operator restores or repairs the service. facilitator_channel_state_unavailable means the refund was found on chain but the provider could not confirm the matching channel state; keep the same drain_id and retry after the provider restores its settlement service.
A channel refund that reached the chain and hasn't finished settling on the provider's books holds the credit: new draws, further top-ups, and any other drain_id are refused settlement_pending (409) naming the settlement, while re-sending that drain's own id still goes through, which is how the reclaim completes. Every one of those refusals names that id in settlement_drain_id while the refund is genuinely unresolved, and the two drain refusals also keep drain_registered: true, so a caller who no longer holds it re-posts the named one rather than minting another into the same refusal. Once the provider has closed the refund out without paying it, draws and top-ups stay refused. The balance is reclaimable but not spendable, and that id is withheld, because a fresh drain_id is what reclaims from then on; settlement_status is what tells the two states apart. The top-up refusal lands before anything is charged, so a deposit is never taken against a channel in this state, and it reads for a funder: it says the deposit was refused and points at reclaiming the balance already there rather than at paying per call. The balance stays yours throughout. Spending pauses; ownership doesn't.
Re-stating a different method/payout under a recorded drain_id is refused with drain_conflict: the destination the money moved for is immutable. Use a fresh drain_id for the next reclaim.
When the provider signs receipts, every reclaim event (requested, parked, picked_up, sent, released) is countersigned into a DrainReceipt (kind: "credit_drain", signed by the attested receipt key) returned in the drain's receipts[]. Your signed drain request plus the provider's requested receipt form a portable IOU: an unfulfilled drain is provable from artifacts both sides already hold.
Credit failures are not a separate family: they use the same envelope and appear in the same code list as every other payment failure. See Payment errors.
Payment errors
Every payment failure (on POST /v1/job, on POST /v1/job/:id/messages, and on POST /v1/credit) answers with one flat body. There is no nesting and no per-rail shape: a caller parses the same five fields whichever rail it paid on and whichever endpoint refused it.
{
"error": "payment_insufficient",
"message": "expected 100000 msats, received 50000",
"display": "The payment was less than the amount required, so it didn't cover the request. The full amount is still owed.",
"hint": "Retry with a single payment of required_msats — a partial payment does not reduce what is owed.",
"retryable": true,
"required_msats": 100000,
"received_msats": 50000
}
| Field | Type | Required | Description |
|---|---|---|---|
error | string | yes | Machine code: the value to branch on. See Error codes |
message | string | yes | Operator-facing detail for logs. Its wording is not part of the contract |
display | string | yes | One sentence to relay to a human verbatim |
hint | string | yes | The actionable next step, also for a human. May be situational |
retryable | boolean | yes | Whether re-sending the same request could ever succeed |
Code-specific fields are merged in after these and vary by code: required_msats / received_msats above, remaining_micro / shortfall_micro on a credit shortfall, fundable_micro on a top-up over the ceiling, expected_micro on a funding-amount mismatch. The five fields are always present.
display and hint are prose written for the human an agent is relaying to: no rail names the caller didn't choose, no protocol jargon. Relay them, don't branch on them: error and retryable are the machine surface, and a caller that pattern-matches on copy breaks when the copy improves. hint is situational by design: a sick-mint refusal names the mints that are healthy right now, and a short mid-job payment names the credit its money landed on.
retryable says whether the identical request could ever succeed, not that it should be re-sent immediately. insufficient_credit is retryable because topping up first makes the same draw work, not because an immediate retry would.
The 402 challenge
payment_required is the one 402 body that is not an error report. It is the challenge a provider answers an unpaid request with (nothing failed, the caller simply hasn't paid yet), so it deliberately carries no display, hint, or retryable, and it is not in the code list below.
{
"error": "payment_required",
"required_msats": 100000,
"price_micro": 100000,
"price_currency": "usd",
"mints": ["https://mint.example"]
}
required_msats is the canonical settlement number. mints is present when the provider takes Cashu. A provider offering prepaid credit also attaches the funding menu, so one round trip tells a caller both what this job costs and that it could prepay instead.
price_micro is the same job's price in 1e-6 units of price_currency: the provider's exact integer, resolved against the same fx snapshot as required_msats beside it. It is what a draw values its funding payment against, so both inputs to that derivation come off this one envelope and first contact needs no /v1/quote hop. Both fields are absent on a free job and on providers that predate them.
Two rails extend the challenge rather than replacing it, and x402 does so two different ways depending on which generation the caller speaks. x402 v1 (legacy) merges x402Version and accepts straight into the JSON body shown above, but only while v1 exact is being served alongside it. x402 v2 (the default generation; /v1/info#x402.dual_serve serves both at once) carries the same information in a PAYMENT-REQUIRED response header instead, an encoded { x402Version, accepts, resource, extensions, error? } envelope. Writing that header never touches the JSON body: a v2-only facilitator, or a batch-settlement-only 402 such as a /v1/credit refund challenge, carries no x402Version/accepts fields in the body at all. Pay a v2 challenge by signing PAYMENT-SIGNATURE on the retry; a v1 challenge signs X-PAYMENT. Tempo adds one WWW-Authenticate: Payment <challenge> header per advertised method. Every extension also sets Cache-Control: no-store.
Error codes
| Code | Status | Retryable | Meaning |
|---|---|---|---|
payment_invalid | 402 | no | Payment present but unusable: unverifiable, or a top-up on a rail the credit wasn't funded on |
payment_insufficient | 402 | yes | Paid less than required_msats. The retry owes the full amount, never the difference |
payment_unapplied | 500 | yes | Payment verified and held as prepaid balance, but a provider fault stopped it being applied to the job |
cashu_unavailable | 402 | no | Provider can't accept Cashu right now: misconfiguration, not a caller problem |
cashu_not_p2pk_locked | 402 | no | Provider requires P2PK-locked tokens and the request carried an unlocked one |
invalid_token | 402 | no | Token was malformed or from an unsupported mint |
not_p2pk_locked | 402 | no | Token was unlocked, or its lock had already expired |
wrong_lock_pubkey | 402 | no | Token locked to a key this provider doesn't hold: re-read cashu.lock_pubkeys from /v1/info |
proof_not_unspent | 402 | no | Proofs were already spent at the mint |
proof_pending | 402 | yes | Proofs are still settling at the mint |
missing_request_id | 402 | no | The accumulator path requires an X-Cashu-Request-Id header |
missing_lock_pubkey | 402 | no | Provider has no lock key wired: misconfiguration, not a caller problem |
replay_rejected | 409 | no | These proofs already landed under this request id. Don't re-send: the money is not lost |
mint_unreachable | 402 | yes | The mint didn't respond, or returned an invalid response |
mint_health_pending | 503 | yes | Provider just started and is still validating its mints |
cashu_mint_sick | 503 | yes | The token's mint is unhealthy. hint names the mints that are currently healthy |
insufficient_credit | 402 | yes | Draw exceeds spendable balance. Carries remaining_micro, balance_micro, shortfall_micro |
credit_expired | 402 | yes | Past TTL; refuses new draws. The balance is preserved and stays drainable |
credit_unbacked | 409 | no | This credit's Tempo channel finalized before the service could collect the value it had accepted. No drawable or reclaimable balance remains: don't retry, top up, or drain it |
credit_not_found | 402/404 | yes | No such credit for this caller |
tempo_channel_closing | 409 | no | The Tempo channel for this credit has started closing; it cannot accept a new job |
credit_not_supported | 501 | no | This provider doesn't offer credit: pay per call |
one_shot_credit_not_supported | 409 | no | This provider takes prepaid credit on a reusable stablecoin channel only. Nothing moved: fund a channel, or pay per call with the one-payment method |
credit_below_min | 400 | yes | Top-up under min_micro |
below_rail_minimum | 400 | no | Under this rail's own floor. Carries rail, min_micro, alternatives: resize or switch rail |
credit_over_max | 400 | yes | Top-up would push the residual over max_micro. Carries fundable_micro |
credit_id_unavailable | 400/402/409 | yes | Not available to this caller: a reserved namespace, or an id someone else holds |
draw_conflict | 409 | no | draw_id reused for a different request |
funding_commitment_mismatch | 402 | no | Artifact doesn't hash to fund.commitment, or its value isn't fund.amount_micro |
lightning_settlement_pending | 402 | yes | An invoice is outstanding for this fund_id. Pay it and re-send the same request |
lightning_invoice_expired | 402 | no | The invoice expired unpaid. Nothing was charged: start over with a fresh fund_id |
lightning_unavailable | 503 | yes | Provider can't issue an invoice right now. Carries the funding rails that still work |
nothing_to_drain | 409 | yes | No spendable balance to reclaim (zero, or fully held by in-flight jobs) |
drain_below_dust | 409 | no | Real balance remains, but at the rate it was funded it is worth no more than it costs to send back, so there is nothing payable. The balance is untouched: a top-up makes the whole amount reclaimable, and it stays spendable either way |
drain_not_found | 404 | yes | Polled a drain_id this credit has never seen |
drain_method_unsupported | 400 | no | Not a payout rail this provider reclaims over. Carries supported_methods: reclaim as ecash with cashu, or on a channel-funded credit its own rail |
drain_conflict | 409 | varies | The refund was refused, and which refusal it is decides whether to retry: the response's own retryable is authoritative. It clears on its own when another job on the credit is still running, or when the balance moved while the refund was settling (nothing was recorded, so retry with a fresh drain_id once the jobs finish). It never clears when the drain_id was reused with a different method or payout: recorded destinations are immutable. It also never clears when a cooperative channel refund reached the chain and paid less than the balance being reclaimed: that money arrived, nothing was booked here, and only the provider's operator can reconcile the difference. Keep the drain_id (drain_registered is true) and report it to them, since a fresh one lands on the same refusal |
tempo_channel_exit_required | 409 | no | The Tempo channel cannot close cooperatively; use its on-chain exit instead |
x402_settlement_ambiguous | 503 | yes | The stablecoin authorization may already have settled, but the service has not yet recovered the exact chain result. Retry the same request with the same signed payment; do not sign or pay again |
x402_settlement_submission_failed | 503 | yes | The service couldn't submit the channel payment to the chain; nothing was charged, and the voucher remains valid |
settlement_not_on_chain | 402 | yes | No matching x402 refund was found on chain; the balance remains in the channel. Retry the same drain_id |
chain_unreachable | 402 | yes | The service cannot read the chain to determine whether the x402 refund moved. Keep the same drain_id |
settlement_unbookmarked | 402 | no | The x402 refund has no chain checkpoint, so its outcome cannot be checked safely. Contact the operator |
facilitator_channel_state_unavailable | 402 | yes | The x402 refund is on chain, but the service cannot confirm the matching channel state. Keep the same drain_id |
settlement_pending | 409 | no | An earlier channel refund on this credit hasn't finished settling, so the balance can be reclaimed but neither spent nor topped up. Carries settlement_id and settlement_status, plus settlement_drain_id: the one drain_id still admitted, absent once the refund has been closed out and only a fresh one reclaims |
credit_funding_unrecorded | 500 | no | The payment settled but the provider recorded no balance. Don't retry: contact the operator |
funding_commitment_mismatch is retryable: no on both its branches, and that stays true of the Cashu corrected re-sign described under Drawing against a credit: the retry that lands carries a different fund.amount_micro, so it isn't the same request. Its amount branch carries expected_micro; its commitment branch carries nothing, which is how a caller tells the two apart without reading message.
Every code above credit_funding_unrecorded belongs to the provider SDK's PaymentErrorCode union, and every member of that union appears in the table. The union is exhaustive by construction: a new code can't ship without its display / hint / retryable copy. credit_funding_unrecorded is raised outside that union and supplies the four fields by hand, so it's the one code whose copy no compiler guards.
HTTPS transport
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /health | Health check (no auth required) |
GET | /v1/info | Provider metadata |
POST | /v1/quote | Price quote for a job |
POST | /v1/job | Submit a job |
POST | /v1/request-result/:request-id | Read one authenticated request result |
POST | /v1/request-resume/:request-id | Materialize one committed acceptance |
GET | /v1/job/:id | Job status |
GET | /v1/job/:id/messages | Messages (SSE stream or JSON snapshot) |
POST | /v1/job/:id/messages | Caller sends a message |
DELETE | /v1/job/:id | Cancel a job |
POST | /v1/credit | Prepaid credit: top up, check balance, drain |
GET /v1/info
Returns provider metadata. A DVM runs on one of two compute tiers: container, the builder's own Dockerfile-based SDK server deployed as a standalone machine, or isolate, a handler-only deploy with no Dockerfile that runs inside dvmkit's shared platform runtime. /v1/info differs by tier in a few fields, marked below.
{
"name": "Web Extract DVM",
"description": "Extracts clean markdown from web pages",
"tags": ["web", "extraction"],
"auth": ["secp256k1-schnorr-v2"],
"auth_audience": { "dvm_id": "web-extract", "builder_pubkey": "3f9c…" },
"protocol_version": 1,
"sdk_sha": "9f2c1ab4d0e7",
"receipts": true,
"request_recovery": true,
"credit": {
"min_micro": 100000,
"max_micro": 5000000,
"ttl_ms": 2592000000,
"currency": "usd",
"funding": ["cashu", "x402"]
},
"owner": { "handle": "acme", "display_name": "Acme Labs", "avatar_url": null, "type": "org" },
"builder": {
"pubkey": "3f9c…",
"attestation": { "dvm_id": "web-extract", "deployed_at": 1790000000 },
"signature": "a41b…"
},
"mints": ["https://mint.example"],
"payment": { "methods": ["cashu", "x402"] },
"capabilities": {
"extract": {
"description": "Extract clean markdown from one URL",
"input_schema": {
"type": "object",
"properties": { "url": { "type": "string" } },
"required": ["url"]
},
"example": { "url": "https://example.com" },
"pricing": { "max": { "amount": 0.05, "currency": "usd" } }
}
},
"x402": { "pay_to": "0xab…", "network": "base", "asset": "USDC", "dual_serve": true },
"cashu": {
"mints": ["https://mint.example"],
"lock_pubkeys": ["02c4…"],
"lock_pubkey_grace_seconds": 3600,
"min_locktime": 604800,
"canonical_id": "web-extract"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Display name |
tags | string[] | yes | Freeform tags describing capabilities |
auth | string[] | yes | Advertised auth schemes — "secp256k1-schnorr-v2" or "none". See Authentication |
auth_audience | object | no | { dvm_id, builder_pubkey } target for v2 caller proofs; both values match the attested builder block |
protocol_version | number | yes | Protocol version (currently 1) |
sdk_sha | string | yes | Git SHA the deployed image was built from; "unknown" when unavailable |
description | string | no | Human-readable description. Omitted when empty |
owner | object | no | { handle, display_name, avatar_url, type } — who operates this provider |
builder | object | no | { pubkey, attestation, signature } — builder identity attestation. Omitted when unsigned |
receipts | boolean | no | true when the provider signs a receipt for every terminal job. Omitted — never false — otherwise |
request_recovery | boolean | no | true when authenticated request-id result and guarded resume routes are durably backed |
credit | object | no | Public prepaid-credit terms: { min_micro, max_micro, ttl_ms, currency, funding, tempo?, x402?, lightning_min_micro? }. Container tier only; never carries a caller ID or balance |
mints | string[] | no | Cashu mint URLs accepted |
payment | object | no | { methods } — rails accepted, from "cashu", "x402", "tempo". Emitted by both tiers |
capabilities | object | no | Per-capability advertisement, keyed by capability name (see below) |
pricing | object | no | { max: { amount, currency } } — fiat ceiling. Isolate tier only |
input_schema | object | no | Mirror of the sole capability's input schema. Isolate tier only, single-capability |
quote | object | no | { schema } — mirror of the sole capability's quote schema. Isolate tier only, single-capability |
x402 | object | no | { pay_to, network, asset, facilitator?, dual_serve }. Container tier only |
tempo | object | no | { methods: [{ method, intent }], chain_id, withheld?, realm }. chain_id is the positive integer chain every advertised Tempo challenge uses. Container tier only |
cashu | object | no | { mints, lock_pubkeys, lock_pubkey_grace_seconds, min_locktime, canonical_id }. Container tier only |
When receipts: true, builder.attestation.dvm_id is the provider's immutable trust identity. Every signed job receipt and credit-drain receipt carries the same required dvm_id; its dvm field remains signed display metadata, not an identity. A verifier must reject a receipt whose ID differs from the attestation before accepting its receipt key.
Each entry under capabilities describes one capability:
| Field | Type | Required | Description |
|---|---|---|---|
description | string | no | Human-readable summary of what this capability does |
input_schema | object | no | JSON Schema for the capability's job input |
example | object | no | A runnable example input, preferred by callers over schema-derived placeholders |
pricing | object | no | { max: { amount, currency } } when statically priced, { quote: { schema } } when dynamic |
Where the price lives depends on the tier. A container-tier provider advertises its fiat ceiling per capability at capabilities.<name>.pricing.max and never emits a top-level pricing. The isolate tier emits top-level pricing.max instead, and for a single-capability provider also mirrors that capability's input_schema and quote schema to the top level. A caller should read capabilities first and fall back to the top-level fields — in practice that fallback is where an isolate-tier reader lands for a statically-priced capability, since the isolate tier's own capabilities entries carry a quote schema but essentially never a pricing.max of their own.
Whichever tier serves it, max is the advertised ceiling, not a promise about a given input: a dynamic-priced capability omits max, carries pricing.quote.schema instead, and lets /v1/quote answer with the real upfront per input. A free capability omits pricing entirely.
POST /v1/quote
Request body: { "input": "https://example.com" }
{
"upfront": { "amount": 0.05, "amount_micro": 50000, "currency": "usd" },
"description": "scrape https://example.com — $0.005 base + worst-case $0.009 size + worst-case $0.034 extract",
"shape": "fixed",
"mints": ["https://mint.example"]
}
The fiat-first envelope (DVM-250):
upfront— the protocol-enforced fiat commitment the caller commits to before submitting the job. The SDK derives sats from this and a snapshot fx rate at settlement time.amountis the rendered decimal;amount_microis the same price as an exact integer in 1e-6 units ofcurrency, and it is the provider's own ledger figure rather than a restatement of the decimal. Prefer it wherever the exact price matters — a provider that rendersamountat a coarser precision than it charges at (four decimals is common) cannot be reproduced from the decimal at all. It is absent on a free quote and on providers that predate it.description— prose explanation of what the upfront buys and how pricing works. LLM agents read this directly.hint(optional) — follow-up prose (e.g. "After the transcription completes, you'll be charged the per-minute rate × actual minutes.").shape(optional) —"fixed"for fully-known prices,"rate"for metered.rate(optional, whenshape === "rate") —{ per_unit: { amount, currency }, unit, max_units? }.
Sats binding happens at the /v1/job 402 handshake — required_msats in the 402 envelope is the canonical settlement number; the upfront figure on /v1/quote is indicative within the 5-minute cache window.
An input the capability's schema rejects comes back 400 invalid_input here, the same answer POST /v1/job gives the same body — so a caller can correct against the quote and know the submit will get past the same gate. See Signed requests. A body carrying no input at all is a price probe rather than a violation, and still quotes: reading what a capability costs must not require having assembled a request for it yet. POST /v1/job refuses that body, since a submit with nothing to run is a mistake.
Providers that offer prepaid credit also return a credit block here. Unlike /v1/info, a signed quote may append the caller's credit ID and balance echo. See Prepaid credit.
POST /v1/job
Submit a job for processing.
Request body:
{
"input": "https://example.com",
"params": { "format": "markdown" }
}
Payment (if required): attach X-Cashu: <token> header.
Response codes:
- 200 — job completed inline. There is no configurable threshold: every submit waits one tick after the handler starts, and if the job went terminal in that tick, a further bounded wait for the receipt to finish signing. Whatever the job's status is once that resolves is what decides the code.
{ "job_id": "...", "summary": "...", "receipt": { ... } }when the DVM signs receipts — see Job receipts - 202 — job accepted for async processing:
{ "job_id": "..." } - 402 — payment required, invalid, or insufficient. An unpaid request gets the 402 challenge; a payment that fails verification gets a payment error
- 409 — the payment or the idempotency key was already used for a different request:
replay_rejected,draw_conflict - 503 — the provider can't verify payments right now:
mint_health_pending,cashu_mint_sick
Overpayment is kept, not returned — nothing comes back on the response. On an unsigned request the whole amount presented is drawn against the job, so a caller who overpays is charged what they sent. On a signed request carrying a credit envelope only the quoted price is drawn and the excess stays as available balance, spendable on later jobs or reclaimable with drain. Either way, send the quoted price.
A 402 from a credit-offering provider also carries the credit funding menu, so a caller learns at the same moment that it could prepay instead. Signed bodies may additionally carry credit_id / draw_id / fund to pay from a standing balance — see Prepaid credit.
Public request recovery
A provider advertising request_recovery: true accepts an optional request_id inside the signed data object on POST /v1/job. It is a protocol field rather than capability input, so the server verifies it as part of the caller's Schnorr-signed bytes and then strips it before capability-schema parsing. A public id is 1–128 characters from letters, digits, dot, underscore, colon, and hyphen.
Before pricing, payment verification, a credit draw, or handler execution, the provider atomically binds (caller pubkey, request_id) to the full request fingerprint, resolved requester identity, and a stable preallocated job id. That pair is unique in durable storage and a live database lock serializes the complete paid-accept path across server processes. A concurrent request cannot reach a payment verifier or handler. A different caller, body fingerprint, or requester identity cannot adopt the binding.
An unpaid 402 discovery response releases the live lock but retains the durable binding and stable job id. The exact caller and body may then submit the original payment once; a mismatch or concurrent holder receives 409 request_id_exists. If a payment or draw commits before the job row can be saved, its acceptance draw is linked to that stable job id so guarded recovery can finish the original job without consuming money again.
POST /v1/request-result/:request-id is strictly read-only. Its JSON body is the original POST /v1/job body with the same request_id and a fresh signed-request envelope. The URL id, signed id, caller pubkey, resolved requester identity, capability, and complete request fingerprint must all match the saved job. A missing record or any mismatch returns the same 404 request_not_found body with status: "unknown"; absence is never converted into failure and never creates a replacement.
An exact result returns 200 for a terminal job or 202 for a non-terminal job:
{
"request_id": "agent-run-018f",
"job_id": "job-abc-1",
"status": "completed",
"transaction_hash": "0x8e5a…",
"summary": "Done",
"receipt": { "v": 1, "job_id": "job-abc-1", "outcome": "completed", "signature": "…" }
}
job_token is optional and may be returned only after the same caller and fingerprint checks. Clients must validate the echoed request_id, a non-empty job_id, one of processing, awaiting-input, completed, failed, or cancelled, the 200/202 status pairing, and the shape and job binding of every optional transaction hash, token, artifact field, and receipt before persisting caller state. A malformed response leaves the outcome unknown.
POST /v1/request-resume/:request-id is the only public mutating repair. It takes the same fresh signed body but no payment, wallet, bearer, or chain credential. The provider may acquire only an existing exact request binding; it may create and run the job only from the sole still-pending acceptance draw already linked to that binding's stable job id. The route does not read current pricing, invoke dynamic quoting, settle Lightning, verify payment, debit credit, or enter a handler unless that committed draw exists. A later price change, including a change to free, cannot authorize work.
No existing exact binding or no committed acceptance draw returns 409 without mutation:
{
"error": "request_not_accepted",
"request_id": "agent-run-018f",
"server_acceptance": "absent",
"payment_state": "unknown"
}
This generic route does not replace Cashu's proof-bearing pending-submission replay. If an initial paid POST never reached the provider, direct x402 and Tempo authorizations are not reconstructed or replaced by request resume, and Cashu proof recovery stays in its separate caller wallet journal. If the server already saved or completed the job, clients should stop at request-result; calling resume is unnecessary and must not repeat work.
GET /v1/job/:id
Job status check.
{
"job_id": "job-abc-1",
"status": "completed",
"summary": "...",
"receipt": { "v": 1, "job_id": "job-abc-1", "outcome": "completed", "seq": 482, "receipt_pubkey": "8a1b…", "signature": "c92e…" }
}
Status values: "processing", "completed", "failed", "awaiting-input", "cancelled", "working". receipt rides this response too, on the same terms as the POST /v1/job 200 — see Job receipts.
Job receipts
When a provider signs receipts (/v1/info#receipts is true), every job that reaches a terminal status carries a receipt block: on the synchronous POST /v1/job 200 and on every later GET /v1/job/:id. It is signed once, at the terminal transition, by the per-DVM receipt key — the same bytes are served on every read, never recomputed.
{
"v": 1,
"job_id": "job-abc-1",
"dvm_id": "web-extract",
"dvm": "web-extract",
"capability": "extract",
"outcome": "completed",
"reason": null,
"seq": 482,
"issued_at": 1790000000,
"requester_pubkey": "3f9c…",
"paid": {
"msats": 50000,
"rail": "cashu",
"native_amount": 50000,
"native_asset": "sat",
"tx_hash": "…",
"mint": "https://mint.example"
},
"result_hash": "…",
"receipt_pubkey": "8a1b…",
"signature": "c92e…"
}
| Field | Type | Description |
|---|---|---|
v | number | Receipt schema version. Currently always 1 |
job_id | string | The job this receipt attests |
dvm_id | string | Immutable DVM identity bound into the /v1/info#builder attestation — the trust anchor |
dvm | string | DVM slug, as bound in the same attestation. Signed display metadata, not identity |
capability | string | Capability the job dispatched to |
outcome | string | Terminal outcome: completed, failed, or cancelled |
reason | string | null | Terminal reason on failed/cancelled (e.g. worker_died_mid_job). null on success |
seq | number | Per-DVM monotonic terminal counter. A gap exposes receipt suppression |
issued_at | number | Unix seconds when the receipt was signed |
requester_pubkey | string | null | Caller's x-only pubkey when signed-request auth was used; null otherwise |
paid | object | What the caller paid — msats, rail, native_amount, native_asset, tx_hash, mint. Every other field is null where it doesn't apply |
result_hash | string | null | Hash over the delivered result; null when nothing was delivered |
receipt_pubkey | string | x-only secp256k1 pubkey that signed this receipt (64 hex chars) |
signature | string | BIP-340 Schnorr signature over the canonical receipt minus signature (128 hex chars) |
credit | object | Optional. Credit-draw countersignature, present when the job's payment funded/drew the credit ledger: { credit_id, draw_id, amount, balance_after, ledger_seq } |
New receipts include optional signed timing: { accepted_at_ms, terminal_at_ms, caller_wait_ms }: acceptance and terminal timestamps are Unix milliseconds, and caller wait is the accumulated time awaiting input, including payment. Time spent awaiting the caller is excluded when computing provider processing time. Legacy receipts may omit timing; missing values are not reconstructed.
Failed and cancelled receipts also include optional signed ended_by: "caller" | "provider". Caller cancellation or a timeout while awaiting input is attributed to the caller. Handler failures, processing timeouts and other unsuccessful provider endings are attributed to the provider. Completed receipts omit ended_by. These fields retain receipt version 1; verification includes every field that is present.
Verify dvm_id against the attestation before trusting receipt_pubkey — dvm is signed display metadata, not identity, so a rename doesn't change which key a receipt has to chain to. Credit-drain reclaims are countersigned the same way, into a separate DrainReceipt under the same receipt key — see the drain op under Prepaid credit above.
GET /v1/job/:id/messages
Two modes based on the Accept header:
SSE streaming (Accept: text/event-stream). Opens a persistent connection. Sends existing messages from the backlog first (those with seq > after parameter), then pushes new messages in real time. The stream closes when the provider sends a yield message.
GET /v1/job/job-abc-1/messages?after=0 HTTP/1.1
Accept: text/event-stream
event: message
data: {"seq":1,"from":"provider","type":"text","content":{"text":"Working..."}}
event: message
data: {"seq":2,"from":"provider","type":"complete","content":{"summary":"Done"}}
JSON snapshot (default). Returns all messages after the cursor and closes immediately. Use for non-blocking check-ins after a working yield.
{ "messages": [{ "seq": 3, "type": "text", "content": { "text": "Still working..." } }] }
POST /v1/job/:id/messages
Caller sends a message into the conversation. The sender must be the original job requester (verified by auth token).
{ "type": "response", "content": { "prompt_id": "p1", "text": "Option B" } }
Returns 201 { "seq": N } on success.
For payment messages, the server verifies the token/preimage before processing. Returns 402 on failure.
A short mid-job payment is kept, not discarded: the amount lands on a credit the payer owns, and the 402 names it — { "error": "payment_insufficient", "required_msats": …, "received_msats": …, "credit_id": "…", "credited_micro": …, "credit_currency": "usd" }. That balance is not progress on the ask, so the retry still owes the full required_msats; the held amount is reclaimable with op drain at POST /v1/credit (see Prepaid credit). The three credit fields appear only when the payment actually funded a credit — a provider with no credit ledger omits them.
A mid-job payment that a provider verifies but cannot apply to the job answers 500 { "error": "payment_unapplied", "credit_id": "…", "credited_micro": …, "credit_currency": "usd" }. The money was received and is held as prepaid balance the payer owns. The job's request is simply still outstanding. Satisfying it takes a new payment; re-sending this one is rejected as already used. Unlike the short-payment case above, the held amount is not immediately drainable: it stays allocated to the job until the job reaches a terminal status, and op drain reclaims it after that. One case is deliberately less definite: when the provider could not read the job row back either, both display and hint say the outcome is unknown rather than telling the payer to pay again. Read the job's status first: a payment that did reach the job leaves nothing outstanding and a second one is charged on top. A provider that holds no prepaid balances omits the credit_* fields and says so in the copy: there is nothing to reclaim, whatever the job ends up charging.
The success response carries the same three fields under a credit key — 201 { "seq": N, "credit": { "credit_id": …, "credited_micro": …, "credit_currency": … } } — when a payment funded a credit the job did not end up drawing from. That happens when two payments for one request land at once, or when one arrives after the request has already been met: all of them are credited to the payer, one pays the job and the rest stay the payer's balance. Don't re-send a payment that comes back with a credit block — the job's request was already satisfied. A job is never charged more than it asked for in total, so an amount above the request is balance rather than a charge.
When that balance is reclaimable depends on which case it is, and the hint says which. Money the job never charged for is available at once — spend it on a later request, or reclaim it with op drain at POST /v1/credit immediately. Money a payment placed against the job before losing the race is held until the job reaches a terminal status, and op drain reclaims it after that. Neither field set is ever the only record: every credit a payment opens is listed by op balance at POST /v1/credit under the payer's key.
DELETE /v1/job/:id
Cancel a job. Optional body: { "reason": "No longer needed" }.
Returns { "status": "cancelled" }.
Authentication
The server declares its supported authentication schemes in the auth array of the /v1/info response. Two values are defined: "secp256k1-schnorr-v2" and "none". The retired payload-only "secp256k1-schnorr" format is not accepted.
Signed requests (secp256k1-schnorr-v2)
The protocol's authenticated scheme. A caller is identified by a secp256k1 keypair and proves possession by signing the request itself, so identity is bound to the payload rather than to a credential that could be replayed against a different body.
The signature is BIP-340 Schnorr over secp256k1 — the same key class the builder attestation on /v1/info uses, and a protocol invariant rather than an implementation detail.
The envelope is five fields carried inside the request body, never in an HTTP header: within data on POST /v1/job and POST /v1/quote, and at the top level on POST /v1/credit and custom signed routes.
| Field | Type | Description |
|---|---|---|
pubkey | string | x-only public key, 32 bytes hex (64 characters) |
signature | string | Schnorr signature, 64 bytes hex (128 characters) |
timestamp | number | Unix seconds when the request was signed |
nonce | string | Caller-generated, unique per signed envelope |
auth_statement | object | Versioned audience and operation fields covered by the signature |
{
"op": "balance",
"pubkey": "3f9c…",
"signature": "a41b…",
"timestamp": 1790000000,
"nonce": "kQ7yTn2fV1xR",
"auth_statement": {
"v": 2,
"protocol": "dvmkit",
"protocol_version": 1,
"auth": "secp256k1-schnorr-v2",
"audience": { "dvm_id": "web-extract", "builder_pubkey": "3f9c…" },
"method": "POST",
"path": "/v1/credit",
"capability": null
}
}
What gets signed. Remove signature and auth_statement from the wire body to obtain the exact input, then canonicalise { ...auth_statement, input }: object keys sorted lexicographically at every depth, no whitespace, array order preserved, UTF-8 encoded. The audience is the DVM ID and builder pubkey from the verified /v1/info#builder attestation; the method is uppercase; the path is the concrete mounted pathname without query or fragment; the capability is the outer request capability, or null where none exists. Changing any audience, method, path, capability, or input byte invalidates the proof.
Freshness. A request whose timestamp is more than 300 seconds from the server's clock in either direction is rejected. Callers should sign immediately before sending rather than reusing an envelope.
Replay. The server records (pubkey, timestamp, nonce) and refuses a repeat. On POST /v1/job the tuple is committed only after payment verifies, so an unpaid discovery submit does not burn its nonce and the same job proof may be re-sent with payment. /v1/quote and /v1/job require separate proofs because their signed paths differ. POST /v1/credit commits the tuple up front for balance and drain, which take no payment.
Failures return 401 with a flat body:
{
"error": "auth_error",
"sub_reason": "timestamp_drift",
"display": "Request timestamp is outside the 5-minute drift window",
"hint": "Regenerate the request with the current timestamp + a fresh nonce; sign with secp256k1+BIP-340 Schnorr over the canonical JSON form of the input."
}
sub_reason | Cause |
|---|---|
signature_invalid | Signature didn't verify against pubkey — or the envelope was malformed |
schema_invalid | The signed body violated the endpoint's own request schema — /v1/credit only |
timestamp_drift | timestamp outside the 300-second window |
replay_detected | This (pubkey, timestamp, nonce) was already accepted |
A malformed envelope is reported as signature_invalid rather than getting its own code: telling an unauthenticated caller which half of the signing failed is a free oracle. Providers must not distinguish a wrong pubkey, a bad signature, a key that doesn't match, and a missing or malformed envelope — those are one answer.
A violation of the capability's input schema is a different failure, and on /v1/quote and /v1/job it is not a 401 at all: both answer it with 400 invalid_input, naming the offending fields in message and display and pointing the hint at /v1/info. One caller mistake, one answer, whichever endpoint they hit — a caller discriminates on the code alone rather than matching a status and a sub-reason per endpoint. Disclosing it is not an oracle: that schema is published on /v1/info, so a caller can determine offline — sending no request at all — whether their input satisfies it, and the check runs over the raw body independent of key validity, so it is reachable with any pubkey, valid or not. A body that is both misshapen and badly signed gets the input answer first, because that is the one the caller has to fix before the signature can be judged; a failure naming only the envelope fields is a signing failure and returns the 401 above. schema_invalid survives as a sub_reason on POST /v1/credit, whose schema is the request envelope itself rather than a capability input.
Credit requires this scheme — a prepaid balance belongs to a verified pubkey, so only providers advertising secp256k1-schnorr-v2 offer it.
None
When auth includes "none", requests do not require authentication. This is the default in devMode (local development). The server assigns a default requester identifier.
Bearer tokens are identification, not authentication
A provider may read an Authorization: Bearer <token> header to derive a stable requesterId that follows the caller through the job. That is identification only — the token is opaque to the protocol and is never treated as proof of identity — so "bearer" is not an auth value and never appears in /v1/info. A provider that needs authenticated callers advertises secp256k1-schnorr-v2.
Job credentials (X-Job-Token)
Authentication decides who may submit a job. A second, narrower credential decides who may read one back.
POST /v1/job from an unauthenticated caller returns a job_token alongside the job_id:
{ "job_id": "job-abc-1", "job_token": "9f2c…", "status": "processing" }
It is issued once and never repeated. Present it as an X-Job-Token header on every /v1/job/:id and /v1/job/:id/messages request — status, message reads (JSON and SSE), message writes, and cancel:
GET /v1/job/job-abc-1/messages?after=0 HTTP/1.1
X-Job-Token: 9f2c…
Without it, one anonymous caller could read another's transcript by guessing a job id. Providers that identify callers some other way — a signed submit, or a Bearer token — gate on that identity instead and issue no job_token.
A request that presents no credential at all answers 401:
{
"error": "job_credential_required",
"message": "This job requires the X-Job-Token returned by POST /v1/job.",
"display": "Couldn't retrieve your result — the request was missing its job credential.",
"hint": "Send the job_token from the POST /v1/job response as the X-Job-Token header. The token is minted once, at submit, and the DVM cannot reissue it — a job submitted from another machine, or by a caller that never stored it, can't be read here. If your caller never sends X-Job-Token at all, upgrade: npm i -g @dvmkit/dvm-cli@0.1.4."
}
display stays cause-neutral because two different callers reach this refusal — one that never learned to send the token, and a current one reading a job whose local state doesn't hold it. The conditional advice lives in hint. A provider that identifies callers by Bearer names that alternative in both message and hint.
A request that presents the wrong credential answers 404 {"error":"Job not found"} — deliberately identical to a job id that doesn't exist. A wrong credential must never confirm that a job exists, or the endpoint becomes an id-guessing oracle.
The two are safe to tell apart because the 401 is decided before the job is looked up: it is the same response whether or not the id is real, so it discloses nothing. That split is what makes a stale or misconfigured caller diagnosable — it was an opaque 404 that once left paid results unretrievable with no indication that the fix was "send the token".