API reference
The market-platform exposes a REST API (default base http://localhost:8080,
prefix /api/v1). Authenticated endpoints take a Cognito ID token as
Authorization: Bearer <jwt>; identity is derived from the token, never from
the request body. See Authentication.
MCP is the supported surface. The buyer/seller flow runs over the 13 core MCP tools (
enter_market,purchase,create_listing, …) — plus the three buyer-posting request tools where that surface is enabled — see the MCP tools reference. This REST + SSE surface is the advanced fallback for integrators who can't use MCP, and the only documented home for the flows that have no MCP tool: raw session creation with explicit controls, custom buyer-image push, buyer request posting and request board reads, the signed audit record, arbitrary file upload, and CSV/XLSX/parquet dataset intake.
This reference covers that advanced surface. Shapes are illustrative — fields may be added over time.
Error responses
Every error from this API is JSON:
{ "error": "safe message" }
Some endpoints add a stable machine-readable "code" (the slice-request and
workbook-intake tables below list theirs), and a few add bounded numeric
context such as a rate limit or size cap. Nothing else is added: error bodies
never carry the underlying cause, a stack trace, or an internal identifier.
Two details worth knowing before you write a client.
A browser navigation gets HTML — on 404, and only on 404. A request that
is unambiguously a top-level browser navigation receives a small HTML
"Page not found" page in place of the JSON body when the response is a 404.
The status code is still 404; there is no redirect and no soft-200. A
request qualifies only when all of the following hold:
- the method is
GETorHEAD; Sec-Fetch-Destis absent, or is exactlydocument;- the
Acceptheader explicitly rankstext/htmlaboveapplication/json. Wildcards never count —*/*andtext/*do not qualify — an absent or emptyAcceptnever qualifies, and a tie (Accept: text/html, application/json) resolves to JSON.
So curl (Accept: */*), a browser fetch() (Accept: */*), the Amnetic CLI
and SDKs (Accept: application/json), and any client that sends no Accept
header at all keep the JSON body byte for byte. Send
Accept: application/json if you want to be explicit about it. Every 404
response — JSON or HTML — carries Vary: Accept.
No other status is affected. 400, 401, 402, 403, 409, 413,
422, 429 and every 5xx are JSON for every caller, browsers included. A
wrong method on a valid path is a plain 405 with an Allow header.
The HTML page is byte-identical for every 404 cause and says nothing about
why the request failed: no path, no identifier, no error message, no
cause-specific headers. That is deliberate. The opaque 404 described
throughout this page — where a restricted, inactive, pre-offer, withdrawn, or
simply nonexistent resource all answer identically — is preserved on both
representations, and the HTML page is if anything more opaque than the JSON,
since it does not distinguish an unknown route from an unknown resource.
Consign buyer forensic-marker disclosure
GET /api/v1/consign/disclosure
Fetch the exact current buyer disclosure before accepting. The authenticated
response contains instrument, the content-derived version, the full
lowercase sha256, and the exact document_markdown. Present the markdown to
the buyer and retain the full hash for the POST compare-and-set. This buyer
legal act accepts only a Cognito ID token; an amn_… account API key cannot
reach it. The route is absent while CONSIGN_CANARY_ENABLED is off.
GET /api/v1/consign/disclosure-acceptance
Status for one owned listing (listing_id query). Returns the current
instrument plus accepted for the buyer's newest license grant on that
listing. Missing, foreign, and grant-less subjects are the same 404
disclosure_grant_not_found. Identity is taken from the token, never the
query.
POST /api/v1/consign/disclosure-acceptance
Record the buyer's acceptance that delivered data may carry forensic markers including synthetic records (they know that, not which). The body is:
{
"listing_id": "…",
"accept_disclosure": true,
"disclosure_sha256": "<full lowercase SHA-256 from GET>"
}
The server stamps source=portal, pins the current draft version, and writes
an append-only consign_disclosure_acceptances row for the newest buyer-owned
grant. A stale or missing hash is 409 disclosure_changed. Repeat acceptance
of the same version returns 200 with the original accepted_at. Canonical
source: docs/legal/consign-disclosure.md. The draft is pending counsel
(OD-CE1 / OD-CE3), is not yet effective, and is not legal advice. This surface
does not refuse download; the canary materializer (CE-2c) is the structural
interlock.
Consign attestation-gated refresh
Refresh of a new rotation cycle is gated on a short, versioned use-compliance
attestation. Completing it releases the new cycle's artifact to that license.
Refusing it keeps the gate closed and raises a Category-8 Review-ceiling
candidate. The buyer response is an append-only Consign record; it is not
a trust_grounding_event. These routes accept only a Cognito ID token; an
amn_… account API key cannot reach them. They are absent while
CONSIGN_ENFORCER_ENABLED is off.
GET /api/v1/consign/attestations
Fetch the exact current questions before submitting. The authenticated
response contains instrument, the content-derived version, the full
lowercase sha256, document_markdown, and question_ids. Present the
markdown to the buyer and retain the full hash for the POST compare-and-set.
Canonical source: docs/legal/consign-refresh-attestation.md. The draft is
not yet effective and is not legal advice.
GET /api/v1/consign/deliveries/{id}
Refresh-status for one buyer-owned delivery. Returns whether a newer rotation
cycle is pending, whether attestation is required, and whether the cycle is
released. Missing and foreign delivery ids are the same 404
attestation_delivery_not_found. This is a status read, not a download.
POST /api/v1/consign/attestations
Record a response (every question answered yes) or a refusal. The body
is:
{
"delivery_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"questions_sha256": "<full lowercase SHA-256 from GET>",
"outcome": "response",
"answers": {
"permitted_use": "yes",
"no_redistribution": "yes",
"no_forbidden_training": "yes",
"will_apply_refresh": "yes"
}
}
A refusal omits answers. The server Ed25519-signs the record with the
Consign signing key and writes an append-only consign_attestations row for
the pending cycle. gated_refresh_released is true only for response. A
stale hash is 409 attestation_changed. Repeat of the identical
submit returns 200 with the original row. A later response after a
refusal is a new row and releases the gate. Download of a pending cycle
without a response is 409 attestation_required.
Consign seller canary registration
GET /api/v1/seller/consign/canary-config
Fetch the exact current authorization before registering. The authenticated
response contains instrument, the content-derived version, the full
lowercase sha256, and the exact document_markdown. Present the markdown to
the seller and retain the full hash for the POST compare-and-set.
POST /api/v1/seller/consign/canary-config
Register one seller-reviewed canary set and accept the current server-pinned
canary authorization. This seller-account legal act accepts only a Cognito ID
token; an amn_… account API key cannot reach it. Cognito authentication does
not by itself prove that a person was physically present. The route is absent
unless both CONSIGN_ENFORCER_ENABLED and CONSIGN_CANARY_ENABLED are on. The
latter also requires a dedicated canary KMS key; it never reuses the
encrypt-only Consign Report key.
{
"listing_id": "9dad1234-…",
"license_grant_id": "716cc51e-…",
"dataset_classification": "regulated_ttb_cola",
"accept_authorization": true,
"authorization_sha256": "<full sha256 returned by GET>",
"canaries": [
{
"record_key": "synthetic-record-001",
"value": { "producer": "Example Cellars", "cola_number": "synthetic-outside-real-namespace" },
"plausibility_status": "approved"
}
]
}
dataset_classification is exactly general or regulated_ttb_cola.
form may be omitted: general defaults to seeded, while regulated defaults
to and requires sidecar. General may explicitly request sidecar; regulated
may never request seeded. canaries contains 1–100 items. Each record_key
is unique and at most 4,096 bytes; each value is a JSON object whose canonical
encoding is at most 64 KiB; plausibility_status is pending, approved, or
rejected. Unknown fields and multiple JSON values are rejected. The whole
request body is capped at 8 MiB.
accept_authorization must be true, and authorization_sha256 must exactly
match the full hash returned by the GET. A missing or stale hash returns
409 canary_authorization_changed; fetch and present the current instrument
before retrying. The server still owns and stamps the version and hash.
Seller identity, the current ready content generation, the grant/listing
relationship, authorization version/SHA, cycle index, and acceptance time are
all resolved or stamped server-side. Registration fails closed if the listing
or grant is foreign, the listing is a frozen derived child, or its ready head
changes while values are being sealed. The append-only authorization, rotation
cycle, every encrypted canary, and listings.enforcement_enabled=true commit in
one transaction. Plaintext, ciphertext, record keys, and key ARNs are never in
the response.
New registrations return 201 Created; an exact content replay returns 200 OK
with the original identifiers and accepted_at plus replayed: true:
{
"listing_id": "9dad1234-…",
"content_generation_id": "b2698d7c-…",
"license_grant_id": "716cc51e-…",
"form": "sidecar",
"canary_set_ref": "consign-canary-set-v1:…",
"rotation_cycle_id": "25de776a-…",
"cycle_index": 0,
"authorization": {
"id": "60346429-…",
"instrument_version": "consign-canary-authorization-v0-draft-…",
"instrument_sha256": "…",
"source": "seller_api",
"accepted_at": "2026-08-11T18:00:00Z"
},
"canaries": [
{ "id": "30f9368d-…", "plausibility_status": "approved" }
],
"replayed": false
}
Stable error codes are canary_authorization_required and
canary_config_invalid (400), canary_registration_not_found (404),
canary_generation_not_ready, canary_generation_changed,
canary_registration_conflict, canary_authorization_changed, and
derived_listing_frozen (409), plus a temporary-unavailability 503.
Oversized bodies return 413 with canary_config_invalid.
The operative authorization is currently a non-effective counsel draft (OD-CE1), not legal advice. Its exact content determines the server-stamped version and SHA-256; changing the text creates a new version rather than rewriting prior acceptance evidence.
Consign seller verdict feed
GET /api/v1/seller/consign/cases
Read the authenticated seller's own Consign cases. Cognito identity is taken
from the bearer token and the seller-owned datastore policy scopes every row;
there is no seller or buyer id in the query. Account API keys cannot reach this
seller-human route. The feed is paged with optional limit (1–100, default 50)
and offset (default 0):
{
"cases": [
{
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"kind": "license_enforcement",
"confidence": "critical",
"verdict": "serious",
"case_ceiling": "probable",
"created_at": "2026-08-18T08:00:00Z",
"reviewed_at": "2026-08-18T10:00:00Z",
"evidence_refs": {
"review_id": "01ARZ3NDEKTSV4RRFFQ69G5FAW",
"evidence_package_id": "01ARZ3NDEKTSV4RRFFQ69G5FAX",
"evidence_sha256": "…"
},
"evidence": {
"fact": "the detected use conflicts with the governing license",
"fingerprint_match": "decoded_fingerprint",
"statistical_inference": "category_4:coverage_profile",
"contextual_indicator": "category_7:public_activity",
"alternative_explanation": "independent publication was checked",
"unverified_assumption": "the source artifact is hash-pinned"
}
}
],
"limit": 50,
"offset": 0
}
The current verdict is the newest valid row in the append-only review ledger;
the intake-time consign_cases.verdict snapshot is not used. A case without a
review has verdict: null and no evidence projection. The evidence reference
identifies the sealed package without exposing its object-store bucket/key,
reviewer signature, candidate membership, or private account identifiers. The
six evidence dimensions remain separate: a fact is not a match, inference,
contextual indicator, alternative explanation, or unverified assumption.
Consign Verify
POST /api/v1/consign/verify
Look up one record from a licensed Consign delivery. The route exists only
where CONSIGN_ENFORCER_ENABLED is on and accepts the same amn_… account API
key or Cognito ID-token credential shapes as Report. The body is a closed
object:
{
"record_ref": "AMN1-…",
"record_key": "canonical-unpadded-base64url-token"
}
record_ref is printed in the delivered license notice. record_key is
deterministically derived from the delivered data.parquet row; it is not a
second server-minted secret. Use the listing data dictionary's ordered
primary_key names and the delivered Parquet physical schema/value for each
named column. The v1 byte grammar is:
- UTF-8
consign-record-key/v1, then one zero byte. - For each of the one to three primary-key columns in declared order: unsigned LEB128 byte length plus UTF-8 column name; length plus canonical type; one value type-tag; length plus canonical value bytes.
- Unpadded URL-safe base64 of the complete byte string. The decoded key may not exceed 3 KiB (4096 token characters).
Lengths count bytes, not characters. Unsigned LEB128 is the ordinary little- endian base-128 varint with seven payload bits per byte and the high bit set on every non-final byte. The tag is one literal ASCII byte and is not itself length-prefixed. Canonical types and values are exactly:
| Parquet/Arrow value | Canonical type string | Tag | Canonical value bytes |
|---|---|---|---|
| Boolean | bool |
b |
ASCII 0 or 1 |
| Signed integer | int8, int16, int32, or int64 |
i |
Minimal base-10 ASCII, optional leading -, no leading zeroes |
| Unsigned integer | uint8, uint16, uint32, or uint64 |
u |
Minimal base-10 ASCII, no leading zeroes |
| IEEE float | float32 or float64 |
f |
Go strconv.FormatFloat(value, 'g', -1, bits) ASCII; finite values only and negative zero becomes 0 |
| UTF-8 string | string |
s |
Original UTF-8 bytes |
| Binary | binary |
x |
Original bytes |
| Fixed binary | fixed_binary(<byte_width>) |
x |
Original bytes |
| Date | date32 or date64 |
d |
YYYY-MM-DD in UTC; years 0001–9999, and date64 must be midnight UTC |
| Timestamp | timestamp_s(<timezone>), timestamp_ms(<timezone>), timestamp_us(<timezone>), or timestamp_ns(<timezone>) |
t |
UTC RFC3339Nano; <timezone> is the original empty or valid IANA Arrow timezone string, and the parentheses remain when it is empty, for example timestamp_ns() |
| Time of day | time32_s, time32_ms, time64_us, or time64_ns |
T |
HH:MM:SS with a fractional part only when non-zero, up to nine digits and without trailing zeroes |
| Decimal | decimal32(<precision>,<scale>), decimal64(...), decimal128(...), or decimal256(...) |
D |
Signed unscaled base-10 integer with the scale applied: for non-negative scale, insert/pad a decimal point with exactly scale fractional digits; for negative scale, append -scale zeroes |
Null primary keys, non-finite floats, invalid UTF-8, and temporal values outside
the stated ranges are invalid. Full-mode marking never changes key columns;
preserve-mode payload bytes are unchanged. These executable golden vectors pin
independent clients (each row is one complete record_key):
| Primary key and canonical type | record_key |
|---|---|
id="alpha" (string), revision=7 (int64) |
Y29uc2lnbi1yZWNvcmQta2V5L3YxAAJpZAZzdHJpbmdzBWFscGhhCHJldmlzaW9uBWludDY0aQE3 |
ts=2026-08-11T19:20:30.123456789Z (timestamp_ns()) |
Y29uc2lnbi1yZWNvcmQta2V5L3YxAAJ0cw50aW1lc3RhbXBfbnMoKXQeMjAyNi0wOC0xMVQxOToyMDozMC4xMjM0NTY3ODla |
amount=12345678901234.567890 (decimal128(20,6)) |
Y29uc2lnbi1yZWNvcmQta2V5L3YxAAZhbW91bnQQZGVjaW1hbDEyOCgyMCw2KUQVMTIzNDU2Nzg5MDEyMzQuNTY3ODkw |
blob=00 ff 10 20 (fixed_binary(4)) |
Y29uc2lnbi1yZWNvcmQta2V5L3YxAARibG9iD2ZpeGVkX2JpbmFyeSg0KXgEAP8QIA |
ratio=1.25 (float64) |
Y29uc2lnbi1yZWNvcmQta2V5L3YxAAVyYXRpbwdmbG9hdDY0ZgQxLjI1 |
On success the response is exactly the opaque key and the record fields; it contains no source, canary, confidence, delivery, account, license, listing, or generation field:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{"record_key":"canonical-unpadded-base64url-token","fields":{"COLA":"Palyt"}}
The platform resolves the verified account to its delivery, unexpired license grant, effective rotation cycle, and ready authoritative generation. Invalid, absent, expired, and foreign selectors are deliberately indistinguishable:
HTTP/1.1 404 Not Found
Cache-Control: no-store
{"error":"record_not_found"}
An unwarmed cycle, dependency failure, authoritative/canary key collision, or
failed compliance-ledger append returns 503 with
{"error":"verify_unavailable"}. Every resolved attempt is durably logged
before release. One configured absolute deadline is a ceiling, not response
padding: completed warm work returns immediately and unfinished work fails at
the boundary. Canary envelopes are decrypted only as their cycles enter a
bounded process-local warm overlay, never on the request path; the deployment
gate statistically compares authoritative and canary latency. Wrong methods
remain 405, missing authentication is 401, and while the feature is dark
this path is an ordinary absent-route 404.
Every newly published authoritative generation carries a durable
consign-verify-envelope/v1 internal attestation after all of its records pass
the 3 KiB key and 64 KiB compact-fields caps. Startup checks only that marker
for enforcement-enabled heads and unexpired delivery-bound cycles; it never
rescans a million-row generation during a fleet boot.
Before the first production enablement, run the time-bounded operator command named by any readiness error for each legacy generation:
amnetic-internal --database-url "$DATABASE_URL" consign verify-attest GENERATION_ID --timeout 30m
The command streams the legacy derived index once and transactionally adds only
the attestation metadata after a complete pass. It never rewrites records,
delivery facts, grants, or immutable rotation cycles. If it reports an
incompatible ordinal, keep CONSIGN_ENFORCER_ENABLED off: a replacement head
does not supersede what an earlier buyer actually received. That historical
entitlement remains a cutover blocker until it is no longer reachable or a
separately reviewed serving-envelope version admits it.
POST /api/v1/consign/report
Report a questionable record from a licensed Consign delivery. This endpoint is
available only where CONSIGN_ENFORCER_ENABLED is on; while dark, the route is
absent. It accepts either an amn_… account API key or the normal Cognito ID
token. Both credential paths resolve marketplace identity from the verified
account reference, never from the request body.
Expected body:
{
"record_ref": "AMN1-…",
"record_key": "opaque-record-key",
"where_encountered": "optional description of where this appeared"
}
record_ref is the exact support handle carried by the delivered artifact and
is the only caller field used to locate a delivery. The platform derives the
account, delivery, license, and listing server-side. Do not send any of those
identity fields; they are ignored. A non-empty where_encountered confession is
envelope-encrypted under the dedicated Consign Report key before durable storage.
RecordRefs are high-entropy, unguessable support handles and the lookup is also
scoped to the authenticated owner; clients must not treat the response as a way
to discover or validate them.
After authentication, every request-body byte sequence—including an empty or malformed JSON body, wrong field types, unknown fields, an oversized body, a missing or foreign RecordRef, or an infrastructure failure—receives the same literal response:
HTTP/1.1 202 Accepted
Content-Type: application/json
Cache-Control: no-store
Content-Length: 58
{"status":"Logged. Under review with the data provider."}
The acknowledgment provides no record verdict, ownership signal, correction,
or admission status and is not proof that a report row committed. This invariant
covers the response status, headers above, and body bytes; it does not promise
equal processing-time distributions or timing secrecy. A usable owner-scoped
submission may synchronously perform KMS encryption and a durable database
commit, so its latency can differ from a rejected submission. Missing or invalid
authentication remains an opaque 401 before this invariant begins; a wrong
method on this exact path receives 405, and other paths remain ordinary 404
responses. Triage and any later case promotion are asynchronous, but a durably
admitted report is encrypted and committed before the acknowledgment—there is no
post-response plaintext queue.
Market-entry sessions
POST /api/v1/market/sessions
Create a session (the caller-session handoff). Body (abridged):
{
"caller": {
"agent": { "name": "claude-code", "version": "1.4.2" },
"llm": { "provider": "anthropic", "model": "claude-opus-4-6" },
"source_format": "amnetic-canonical-v1"
},
"session": {
"system_prompt": "optional outer-agent system prompt",
"messages": [ { "role": "user", "content": [ … ] } ],
"workspace_summary": "optional"
},
"handoff": {
"instruction": "Find a dataset of US retail foot-traffic under $50",
"expected_outcome": "optional success criteria"
},
"controls": {
"examination_ceiling": 8,
"suggestion_budget": 1,
"session_timeout_s": 600
},
"image_digest": "sha256:…"
}
The request body is capped at 8 MiB. Required: the caller.* identity fields,
session.messages, handoff.instruction, and image_digest (must reference a
ready image).
201 Created:
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"tier": "standard",
"examination_ceiling_N": 8,
"suggestion_budget_B": 1,
"session_timeout_seconds": 600,
"image_digest": "sha256:…",
"stream_url": "…/api/v1/market/sessions/{id}/stream",
"caller_session_hash": "<sha256 hex of the canonicalized envelope>"
}
GET /api/v1/market/sessions/{id}/stream
Server-Sent Events for a session. Event types:
| Event | Meaning |
|---|---|
suggest_purchase |
A recommendation: { listing_id, reason, ts }. reason is a short platform-owned compatibility value, not inner-agent prose. Up to B per session. |
submit_no_match |
The agent finished with no recommendation: { unmet?: [{ class }], ts } (terminal). class is closed vocabulary only: coverage, freshness, granularity, format, price, trust, rights. |
refuse |
The agent declined or the session failed: { reason, ts } (terminal). reason is a closed enum, not prose. |
session_timeout |
The wall-clock deadline was reached (terminal). |
cancelled |
The session was cancelled by the buyer or an operator. A final audit_id follows when available. |
watchdog_kill |
The platform stopped the session to enforce spend or runtime limits. A final audit_id follows when available. |
audit_id |
Final close event: { audit_id, audit_url, signature_alg, ts }. |
keepalive |
Heartbeat (no payload of interest). |
gap |
Some events were dropped; the audit record is authoritative. |
Other session endpoints
| Method + path | Purpose |
|---|---|
GET /api/v1/market/sessions |
List your sessions (filtered by token identity) |
GET /api/v1/market/sessions/{id} |
Session status |
POST /api/v1/market/sessions/{id}/cancel |
Cancel a running session ({ "reason": "..." }) |
GET /api/v1/market/sessions/{id}/audit |
Fetch the signed audit record. Buyer verification is fixed to Ed25519; unsupported, missing, or malformed signed tuples fail closed. |
The CLI also requires the signed session_id to match the requested route.
Current records bind audit_id, raw_blob_ref, and raw_blob_hash; initial
Ed25519 records that predate those additions remain verifiable but clearly mark
the displayed audit identifier as unbound and cannot trigger an automatic blob
download. Blob URLs must resolve to the exact signed S3 bucket/key, are fetched
without your bearer token, and are never followed through redirects.
Purchases & ownership
You act on a suggestion from outside the wall.
POST /api/v1/purchases
{
"listing_id": "9dad1234-…",
"funding_mode": "wallet",
"offer_id": "immutable offer UUID",
"renew_of_grant_id": "optional grant UUID to renew"
}
funding_mode is wallet (default; deducted from your spendable balance)
or card (charge the full listing price via hosted Stripe Checkout and preserve
existing wallet credit). The legacy payment_method: "credit" field is accepted as a compatibility alias for
funding_mode: "wallet". offer_id selects the immutable license-offer row
and renew_of_grant_id selects a bounded-term renewal; both work on every
funding mode — wallet and card. A missing or unavailable offer is
reported only after listing visibility is established.
When the counsel-gated MCP licensing preview is enabled, enter_market accepts
an enum-only required_rights preference and returns a mechanical
rights_fit.best_offer for each fit. After reviewing the operative exact text,
copy that summary's offer_id into this REST request as offer_id. The recommendation and its
conditional total are advisory snapshots—not legal advice, permission, a price
reservation, acceptance, or a grant. This purchase endpoint re-resolves current
state, and only its completed grant is license evidence. Production's live MCP
schema omits required_rights while the capability remains dark.
For card modes, the elected offer's price (not the listing base price) is
resolved at checkout creation, so the Stripe charge and funded_cents reflect
the offer. The card checkout row persists only the three selectors; the resolved
price, terms, and binding are re-derived server-side at webhook settlement from
the immutable offer row (never a client-supplied snapshot), so a client cannot
force a cheaper or different grant across the async gap.
Wallet purchases complete immediately:
{
"transaction_id": "txn_01ARZ3NDEKTSV4RRFFQ69G5FAV",
"status": "completed",
"payment_method": "credit",
"funding_mode": "wallet",
"already_owned": false,
"purchases": [
{
"listing_id": "9dad1234-…",
"outcome": "purchased",
"offer_id": "offer-...",
"offer_key": "standard",
"terms_hash": "sha256:…",
"grant_id": "grant UUID",
"price_cents": 2500
}
]
}
Offer-level conflicts return 409 with error_code, listing_id, and detail.
Codes are offer_unavailable, price_changed, and renewal_invalid. Listing
visibility failures remain opaque 404. offer_unavailable.detail.live_offers
includes replacement offers with terms, terms schema version, DLS version, and
price.
The request carries no eligibility declaration. Your annual revenue band is an
account setting: declare it once with POST /api/v1/party/representations
({"operand": "annual_revenue_band", "value": "under_1m" | "1m_10m" | "over_10m"}), or in the portal under Account → Eligibility, and read it
back with GET /api/v1/party. Every purchase uses that standing declaration
and the signed Agreement records it. A band-restricted offer returns 409
representation_required until you have declared one and 409 ineligible
(with detail.declared and detail.allowed) when your band isn't allowed; an
offer for legal entities returns 409 party_required until
POST /api/v1/party records your entity. A body that still sends
representations is refused with 400 representations_not_accepted.
Card modes return a hosted Stripe Checkout URL. The webhook funds your wallet and attempts fulfillment after payment; if the listing can no longer settle, the paid amount remains as non-withdrawable spendable credit.
Offer-aware card checkout is live. There is no
LICENSE_TERMS_ENABLEDboot gate. Counsel review still governs how composed terms are interpreted; it does not hide the routes. The drift outcome below is provisional and subject to change: whether a paid-but-drifted card offer should instead be refunded (rather than recorded as non-withdrawable credit) is an open question before counsel and is not yet a settled buyer entitlement.
For an elected offer that is retired, re-versioned, or repriced between checkout
and payment (or a renewal already fulfilled on another rail), settlement resolves
the offer live and, on any drift, currently records the payment as
non-withdrawable wallet credit and mints no license grant (funded_only).
This is intended to be analogous to the wallet rail's synchronous refuse-not-charge
behaviour — the money is preserved as recoverable credit and no wrong grant is
minted — but note the card rail converts fresh card money into non-withdrawable
credit where the wallet rail moves no money at all, and whether that difference
requires a refund is the open counsel question above.
{
"status": "pending",
"payment_method": "card",
"funding_mode": "card",
"checkout_url": "https://checkout.stripe.com/c/...",
"session_id": "cs_...",
"amount_cents": 2500,
"funded_cents": 2500,
"wallet_applied_cents": 0
}
| Method + path | Purpose |
|---|---|
GET /api/v1/purchases/{id} |
Transaction status |
GET /api/v1/ownership |
List listings you own |
GET /api/v1/ownership/{listingId}/download?format=parquet|xlsx|csv |
Presigned download URL for an owned listing. Omit format to download any listing's canonical owned artifact. Explicit parquet, xlsx and csv resolve against the listing's recorded derivation artifacts, so they cover every derived child — platform-created slice children and agent-derived listings alike; an agent-derived listing's canonical artifact is the file its worker staged, in its own format (csv/xlsx name it explicitly), and parquet serves only the platform's own parquet copy of it (never a worker-supplied extra). A listing with no such recorded artifact is unavailable in that format. ?prefer=source (not combinable with format) is the portal's person-default: it serves the staged spreadsheet of an earlier derived listing whose canonical artifact is a platform-converted parquet, and the canonical artifact for every other listing. |
Each GET /api/v1/ownership row embeds a listing object whose
listing.large_listing boolean reports whether the listing is on the resumable
large-upload rail (its advertised size exceeds the server's ordinary-upload cap).
It is computed server-side by the single IsLargeListing predicate and is the
field the portal keys its download affordance off — large listings have no
working in-browser download and must be re-downloaded with amnetic buyer download <listing-id>; small listings download in-browser. Clients should read
this field rather than comparing data_size_bytes to a client-mirrored
threshold, which would drift. The download handle response carries the same
large_listing verdict.
Slice-on-demand deployments also expose the buyer routes below and the workflow
documented in Buying derived listings. Slice children use this same
purchase endpoint unchanged; clients send the offered child listing_id, never
a price assertion or account identity.
Listings with non-clean file malware scan status are treated as unavailable for
purchase. Owned listings can also be temporarily refused at download time while a
file scan is pending or failed closed. Downloads use the same ownership and scan
gates and issue a short-lived URL. When format is omitted, the service returns
the canonical data_ref for every listing. When present, format is a closed
enum (parquet or xlsx); the xlsx alternate is resolved from the listing's
recorded derived artifacts, never caller input. The MCP ownership_download tool
remains the canonical-format surface and does not accept a format argument.
The download response is a re-mintable handle, built so a multi-gigabyte
object survives an expiring URL: when the URL dies mid-transfer, call the
endpoint again and resume with an HTTP Range request.
{
"download_url": "https://…?X-Amz-…",
"expires_at": "2026-08-04T18:31:07Z",
"total_size_bytes": 8589934592,
"sha256": "9f2c…",
"recommended_part_size_bytes": 67108864,
"resumable": true,
"large_listing": true
}
| Field | Meaning |
|---|---|
expires_at |
When download_url stops working. Read this rather than hardcoding a lifetime; operators set it with OWNERSHIP_DOWNLOAD_URL_TTL (default 20 minutes). |
total_size_bytes |
Size of the exact object being served, measured when the URL was signed — this, not the listing's advertised data_size_bytes, is the length your transfer plan and Range arithmetic should use. |
sha256 |
Raw-byte checksum of the served object, returned only when the platform can prove it describes those exact bytes. Omitted otherwise (for example for text-body listings, alternate-format exports, and enforced-delivery bundles). |
recommended_part_size_bytes |
Suggested chunk size for a parallel or resumed fetch. Omitted when the object was not uploaded in parts. |
resumable |
true when re-requesting the handle is cheap and returns the same bytes. false on a delivery-enforced listing, where each call materializes a fresh signed bundle behind a rate limiter — take the URL you were issued and do not poll for a refreshed one. |
large_listing |
true when the listing's advertised size exceeds the server's ordinary (non-large) upload cap — i.e. it was served through the resumable large-upload rail and has no working in-browser download path. Computed by the same server predicate as the ownership row's listing.large_listing, so the two can never disagree. |
matches_evaluated_pin |
Present only if the caller has a released evaluation of this listing. true only when the platform confirmed the exact object being served and its checksum equals the evaluation's pinned_artifact_sha256. false otherwise. |
evaluated_pin |
{evaluation_id, pinned_artifact_sha256, status}. status is match (confirmed, same bytes), changed (the listing changed after the evaluation) or unverified (the platform could not confirm which object it is serving, for example after a pinned-version lookup failed, or for an enforced-delivery bundle). unverified is never reported as a match. |
The CLI does all of this for you: amnetic buyer download <listing-id> [--out <path>] transfers the object as a sequence of Range requests, re-mints the
handle when a chunk fails or the URL is about to expire, resumes from the last
byte written, verifies sha256 when the handle carries one, and renames the
file into place only once the whole object is on disk — so an interrupted
transfer never leaves a truncated file looking complete. --max-retries
(default 5) bounds the consecutive chunk failures it tolerates — a chunk that
succeeds clears the count, so a long transfer is not capped at five failures
overall — and --url-only prints the URL instead of downloading. On a
resumable: false listing it makes a single request and does not retry, for the
reason in the table above. Transfers of 32 MiB or more print a progress line to
stdout every few seconds, so parse --url-only rather than the transfer output
if you are scripting against it.
Download authorization is grant-OR-owner. A caller may download a listing they
purchased (a grant) or a listing whose seller is their own account (the
owner — so a Seller's Agent can fetch the parent it derives from). The owner serve
is scan-gated exactly like a buyer's and, on an enforced listing, is served
verbatim (no delivery marking — the owner already possesses the content). When the
caller neither owns a grant nor is the seller — and equally when the listing does
not exist — the response is a single opaque 404 {"error":"not found"}
(previously 403): missing, not-purchased, and not-owned are byte-identical so a
caller cannot probe another account's inventory. The 409 cases
(scan-blocked, data-missing, unavailable-format) are reachable only after
authorization succeeds.
Enforced delivery (dark; pending counsel)
Off by default. Consign delivery enforcement is gated by
CONSIGN_ENFORCER_ENABLED(default off) plus a per-listingenforcement_enabledopt-in, and stays dark pending counsel. When both are off the download path is byte-identical to the behavior above.
When a parquet-backed dataset listing has enforcement enabled, each download is
served as a signed per-delivery bundle (the authoritative data.parquet
unchanged, plus a license-notice sheet carrying a per-delivery record reference)
rather than the bare object. Every download mints a fresh bundle and records one
append-only delivery-ledger row. Under enforcement the explicit
format=parquet|xlsx|csv selectors are unavailable (the deliverable is the
canonical signed bundle), and the inline body is never returned. If the marked bundle cannot be produced the request
fails closed with 503 materialization_unavailable (retriable — a later
download mints a new delivery) and the plain object is never presigned.
Enforced buyer downloads are admitted through fleet-wide concurrency and fixed-window
attempt limits before any source object is downloaded or delivery identity is minted.
Quota exhaustion returns 429 materialization_rate_limited with an integer
Retry-After header. Capacity exhaustion, datastore failure, and operation timeout
remain the opaque 503 materialization_unavailable. Failed downstream attempts still
count toward the quota, and every admitted retry creates a new delivery bundle; final
artifacts are never reused.
The buyer half of the canary interlock is a separate, versioned disclosure:
GET/POST /api/v1/consign/disclosure-acceptance records that delivered data
may carry forensic markers including synthetic records. Wording is a draft
pending counsel. Missing that row does not block this download path;
canary-bearing delivery (when enabled) refuses in code without it.
Cutover: enabling enforcement on a listing with outstanding ownership rows does not retroactively re-mark anything — each buyer's next download is the first materialized-and-ledgered one; earlier downloads leave no delivery row.
Buyer slice requests
Opt-in beta; pending counsel approval. These REST routes exist only when
SLICE_ON_DEMAND_ENABLEDis on. They are absent—not stubbed—when it is off, and production refuses to start with the flag enabled until the Slice Authorization rider is counsel-ratified and the production path is deployed. An enabled non-production deployment also requiresAWS_REGIONfor the real Bedrock extraction agent andTRUST_RECORDING_ENABLED=truefor transactional parent-to-child provenance.SLICE_MATCH_MODELandSLICE_AGENT_MODELmust be exact reviewed Claude-on-Bedrock profiles from the platform registry, and startup resolves AWS credentials and invokes each distinct configured model with a fixed synthetic preflight. It fails rather than installing a fallback. There are no buyerrequest_sliceorslice_statusMCP tools in this demo.
All three routes require authentication, derive buyer identity from the
verified principal, and return Cache-Control: no-store on success and error.
A missing, foreign, ACL-hidden, or otherwise ineligible parent/job is always
the same opaque 404. The shapes in this section are the closed normative
buyer projection for the beta; the reference's general illustrative-shape note
does not permit internal slice fields to appear here.
POST /api/v1/listings/{id}/slice-requests
Submit multipart/form-data with at most one of each named part:
| Part | Contract |
|---|---|
text |
Optional UTF-8 text, at most 1 MiB. Without a file, each non-empty line is one query; each query must be printable NFC text no longer than 256 UTF-8 bytes. |
file |
Optional non-empty CSV, XLSX, or TXT file, at most 1 MiB. When both parts are present, the file is authoritative and text is ignored. |
At least one non-empty part is required. Duplicate and unknown parts are invalid. The total request-body limit is exactly 2 MiB + 64 KiB (2,162,688 bytes): one 1 MiB budget per legal part plus the bounded multipart envelope. The server also enforces each part's limit independently.
Parent ACL and all other slice eligibility checks run before the body is
interpreted, so malformed input cannot probe an inaccessible listing. A
successful request is free and returns 202 Accepted only after the canonical
query list and job are durable:
{ "job_id": "7d1df2b8-7dbc-4a23-b739-6aef25ac33a4" }
The platform processes the submitted text or file into a canonical query list and does not deliberately store the raw uploaded file after interpretation. For every multi-column file, interpretation sends its column headers and up to five complete sampled data rows across all columns to the configured Claude model on AWS Bedrock. The bounded canonical query list and job evidence are retained for processing and audit. When the flagged D9 seller-only activity/report surface is enabled, the canonical query list and requesting account identity are shared with the seller. The beta publishes no query-retention or deletion control. See Where your data goes for the public model-layer disclosure. Treat submission as a disclosure-bound named order: do not submit query material you are not allowed to share with that seller.
The seller policy's max_jobs_per_buyer_per_day applies to the verified buyer
and this parent listing over the preceding rolling 24 hours. The final count
and insert use the database clock and are atomic. Every accepted job counts
regardless of its eventual outcome; malformed, query-limit, and other
pre-commit refusals do not. The demo does not yet add cluster-keyed, global
cross-listing, or cumulative-row metering.
GET /api/v1/slice-requests
Returns the caller's jobs as a bare newest-first array:
[
{
"job_id": "7d1df2b8-7dbc-4a23-b739-6aef25ac33a4",
"listing": {
"id": "f17a858c-3981-4afb-aea9-0b80345bc475",
"title": "Catalog"
},
"kind": "row_match",
"projected_state": "offered",
"price_cents": 1234,
"created_at": "2026-07-15T18:30:00Z"
}
]
projected_state is exactly processing, offered, not_available,
purchased, or expired. price_cents is omitted unless the projected state
is offered or purchased; an internal checkpoint price is never exposed
while the job is still processing.
GET /api/v1/slice-requests/{job_id}
Every detail contains the closed projected_state and buyer-safe progress.
Optional members are omitted rather than returned as null:
{
"projected_state": "offered",
"progress": [
{ "at": "2026-07-15T18:30:00Z", "line": "Request received." },
{ "at": "2026-07-15T18:30:02Z", "line": "Matching your request." }
],
"offer": {
"totals": {
"queries": 500,
"matched": 412,
"near_miss": 31,
"unmatched": 57
},
"per_query": [
{ "q": "makers mark 46 750ml", "status": "matched" }
],
"price_cents": 1234,
"expires_at": "2026-07-29T18:30:05Z",
"slice_listing_id": "68053322-d174-4848-bd49-8e7c4dfcefc7"
}
}
offer is present for offered and retained for purchased so the frozen
child remains the purchase/download target. Its totals contains exactly
queries, matched, near_miss, and unmatched; it does not contain
rows_selected. per_query is present only when the seller's disclosure mode
was snapshotted as per_query, preserves submitted-query order, and contains
only q plus matched, near_miss, or unmatched. Aggregate disclosure
omits it. Neither mode exposes seller row keys, per-query row counts, candidate
values, match class/confidence, row contents, internal state, or mutable policy.
Progress line is selected from fixed platform copy; stored templates,
arguments, counts, prices, review state, and worker/model prose are never
returned:
| Meaning | Buyer-visible line |
|---|---|
| Request accepted | Request received. |
| Matching | Matching your request. |
| Matching complete | Matching complete. |
| Review/materialization | Preparing your result. |
| Offer ready | Your offer is ready. |
| Purchased | Purchase complete. |
| Expired | This offer expired. |
| No offer | No offer is available. |
Unknown stored templates are omitted, and consecutive entries that map to the same line are coalesced. Valid progress timestamps remain in stored order.
Only terminal selection_too_large and matcher_budget_exhausted may appear
as error_class. selection_too_large carries no selected-row count or cap;
all other failures collapse to not_available without a class.
After the same buyer owns the child and the detail projects purchased, it may
also contain this receipt:
{
"receipt": {
"queries": [
{
"q": "sku 1",
"matches": [
{
"ordinal": 3,
"key_column_values": {
"sku": "SKU-1",
"upc": "012345678905"
}
}
]
}
]
}
}
The receipt contains matched queries only. ordinal is the one-based position
inside the delivered slice, not the parent dataset's ordinal. Key-column values
are JSON scalars. The server reconstructs this view only after authorization
from hash-verified query, match-report, manifest row-key, and immutable profile
evidence. It does not expose row keys/object references/digests, consult a
mutable current policy/profile, or persist a separate receipt artifact.
Slice-request errors
Errors use { "error": "safe message", "code": "stable_code" } plus only
the typed integer fields shown below:
| Status | code |
Additional contract |
|---|---|---|
404 |
not_found |
Exact opaque body { "error": "not found", "code": "not_found" }. |
413 |
slice_request_too_large |
Outer request exceeded 2,162,688 bytes. |
413 |
slice_text_too_large |
limit_bytes: 1048576. |
413 |
slice_file_too_large |
limit_bytes: 1048576. |
422 |
input_invalid |
Wrong content type or malformed/duplicate/unknown/missing/empty/invalid text parts. |
422 |
buyer_file_invalid |
Unsupported, empty, or invalid CSV/XLSX/TXT, including bounded workbook/formula refusals. |
422 |
query_limit_exceeded |
limit is the current caller-applicable parent-policy query cap; no job is created. |
429 |
daily_job_limit_exceeded |
limit is the caller-applicable parent-policy job cap and window_seconds is 86400. |
503 |
slice_request_unavailable |
Temporary interpreter/model/source unavailability before job creation. |
500 |
internal_error |
Generic dependency, persistence, or integrity failure. |
Purchase remains POST /api/v1/purchases with the offered
slice_listing_id. The request has no expected_price_cents, buyer/account
identity, or object key; the frozen child is the quote-integrity boundary.
Buyer requests
Buyer requests are human-signed-off asks backed by escrowed credit. Draft creation does not move money or make anything visible. Sign-off records the three buyer consents, reserves the bounty, and runs platform-side intake moderation before the request can leave the buyer's private workflow.
Where buyer posting is enabled, draft creation, listing your own requests,
browsing the board, and responding as a seller — by submitting a candidate or an
above-bounty quote — are also MCP tools (create_request_draft,
list_my_requests, list_open_requests, submit_request_candidate,
post_request_quote — see the
MCP tools reference). Sign-off and withdraw stay
REST/portal only: sign-off is the portal-only, escrow-reserving human approval
and has no MCP tool.
| Method + path | Purpose |
|---|---|
POST /api/v1/requests |
Create a draft request |
GET /api/v1/requests |
List your requests |
GET /api/v1/requests/{id} |
Get one request |
POST /api/v1/requests/{id}/signoff |
Sign off and reserve escrow |
POST /api/v1/requests/{id}/withdraw |
Withdraw a draft, open, or held-for-review request |
POST /api/v1/requests/{id}/candidates |
Submit one of your listings as a candidate (seller) |
POST /api/v1/requests/{id}/quotes |
Submit one of your above-bounty quotes on a request (seller) — the amount must be a whole number of cents and strictly above the request bounty |
POST /api/v1/requests/{id}/candidates/{cid}/confirm |
Confirm a passed candidate — settles a normal pass at its captured price or a qualifying committed-quote pass at the quoted amount (buyer) |
POST /api/v1/requests/{id}/candidates/{cid}/decline |
Decline a normal passed candidate and keep the request open; after its deadline the server records it as lapsed while the response remains the request object (buyer) |
POST /api/v1/requests/{id}/quotes/{qid}/commit |
Commit to a seller's above-bounty quote — earmark the top-up and reserve the fill window (buyer) |
The buyer confirm/decline/commit routes are the REST twins of the
respond_request MCP tool — the same state machine.
For a normal pass, confirm releases the escrow bounty and buys the candidate's
listing at the price captured when it was submitted (never a later, higher
price); a price drift / withdrawn / already-owned listing is a 409 with the
candidate voided, and an above-bounty price short of the buyer's spendable is a
402.
Confirmation authorization lasts 72 hours from the platform-recorded pass. The
deadline is exclusive: at or after it, confirm cannot charge and decline
records a lapse rather than extending the window.
commit accepts a seller's quote (a bespoke price above your bounty): it
earmarks the top-up above the bounty in escrow and reserves the seller's exclusive
fill window. It settles nothing — the purchase happens automatically at the
quoted amount when the seller delivers. The request stays open. A quote at
or under the bounty is a 400; a spendable balance short of the top-up is a 409.
The unattended fill (the automatic purchase on delivery) and the lapse of
an expired commitment are worker transitions with no REST route.
An in-window seller pass remains eligible for unattended settlement after the
commitment deadline if recovery was delayed: the persisted pass time is checked
against the committed interval inclusively, never against restart time. A pass
from that committed seller inside either exact boundary has earned settlement at
the quoted amount: it cannot be declined or lapsed, and an explicit candidate
confirm routes through the same idempotent quote settlement. With no earned
pass, lapse releases only the quote top-up. The base bounty is released separately
when an idle closing request resolves to expired.
When expires_at arrives with evaluation or quote work still in flight, the
request becomes closing rather than expiring immediately. It accepts no new
work, but existing work may still fill it. Once the last in-flight item ends, it
becomes filled or expired; the latter transition releases the base bounty
exactly once.
POST /api/v1/requests body:
{
"title": "UPC-level bev-alc depletions",
"body": "Monthly UPC-level control-state depletions, 2024-2026.",
"category": "retail",
"tags": ["beverage", "sales"],
"hints": { "freshness": "monthly" },
"bounty_micro_usd": 250000000,
"expires_at": "2026-08-01T00:00:00Z",
"attribution": "named",
"acl_mode": "open"
}
POST /api/v1/requests/{id}/signoff body:
{
"consents": {
"content": true,
"attribution": true,
"commitment": true
}
}
Sign-off can return state: "open" or state: "held_for_review". A held
request has the bounty escrowed but is not eligible for seller-facing surfaces
until an operator releases it. Buyers may withdraw a held request, which releases
the escrow and removes it from the review queue. If an operator rejects it, the
state becomes rejected and the escrow is released. Buyer sign-off is not a
substitute for platform moderation.
GET /api/v1/request-board
Search open, unexpired buyer requests visible to the authenticated seller. Held requests do not appear until an operator releases them. The seller board is authenticated and ACL-filtered for the caller; there is no public request-board route.
Query params:
| Param | Meaning |
|---|---|
q |
Search text |
category |
Exact category filter |
min_bounty_micro_usd |
Minimum bounty, in micro-USD |
mode |
text, vector, or hybrid (hybrid by default; blank q uses text browse) |
limit |
Max results, capped at 100 |
Response:
{
"mode": "hybrid",
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Need California wildfire permit data",
"body": "Looking for county-level permit and incident data...",
"category": "dataset",
"tags": ["wildfire", "permits"],
"hints": { "freshness": "2023-2026" },
"bounty_micro_usd": 25000000,
"expires_at": "2026-08-01T00:00:00Z",
"attribution": "named",
"created_at": "2026-07-07T18:00:00Z"
}
]
}
Candidate submission and passive matching are not part of this REST surface yet; request-board rows are discovery only until the seller action endpoints ship.
Catalog
Public metadata
| Method + path | Purpose |
|---|---|
GET /api/v1/listings |
List listings |
GET /api/v1/listings/{id} |
Get one listing |
GET /api/v1/listings/{id}/license-text/{offerKey} |
Read the operative text for a public offer before purchase (licensing flag only) |
GET /api/v1/sellers/{id} |
Read public seller metadata |
These routes are anonymous JSON APIs, not viewable pages. GET /api/v1/listings/{id} serves a listing only when it is active, its purchase ACL
is open, and — for a listing that belongs to a sealed aggregate (a derived
child, or a workbook root/worksheet) — that aggregate is published. Restricted,
non-active, awaiting-sign-off, and nonexistent listings all collapse to the same
opaque 404 {"error":"listing not found"}, so the route cannot be used as an
existence or disposition oracle. (With licensing enabled, a listing whose offers
are not publicly readable takes the same 404.) Sellers do not have to guess against
that opacity for their own inventory — every seller-facing response for a listing
you own carries a public_visibility object; see
Is my listing in the public catalog?.
public_visibility is {"visible": bool, "reason": "visible" | "not_active" | "restricted_access" | "pending_publication"}, and it is a purely additive
field — no existing field was removed or renamed. It appears on:
| Method + path | Where |
|---|---|
GET /api/v1/seller/documents |
each row |
POST /api/v1/seller/documents |
the created listing |
POST /api/v1/seller/datasets |
the created listing (single-sheet/CSV/parquet; see the multi-sheet note below) |
POST /api/v1/seller/files |
the created listing |
POST /api/v1/seller/large-uploads/{id}/complete |
the created listing |
PATCH /api/v1/seller/documents/{id} |
the updated listing |
POST /api/v1/seller/documents/{id}/publish |
the published listing |
On the write responses in particular, status reports the state you asked for
and public_visibility.visible reports whether the public catalog serves the
listing right now — the two can legitimately disagree while a create or a content
edit is being processed. The field is omitted when the check was unavailable,
which means unknown, never hidden; it never fails the request.
Seller-private derived pricing
Seller listing rows and seller listing write responses (such as
PATCH /api/v1/seller/documents/{id}) carry the read-only, seller-private
derived_pricing object.
It contains enabled, independent row and column axes, min_price_cents,
optional max_price_cents, counter_floor_cents, and optional
buyer_type_adjustments entries for business and enterprise, plus
server-computed row_facts / column_facts. An adjustment is either
{"kind":"multiplier","multiplier_bp":11000} (basis points, where 10000 is
1.0x) or {"kind":"override","price_cents":9000}. All money fields are integer
US cents; axis rates are integer micro-USD. The listing price is the listing's
price_cents; reconstruction facts are measured against it.
The object is a projection of the listing policy. Price and derivation pricing
are written only with PUT /api/v1/seller/documents/{id}/policy: the base
rung's price_cents is the listing price, and the policy envelope and
derivation_pricing carry the bounds, per-row rate, and buyer adjustments.
PATCH /api/v1/seller/documents/{id} accepts only title, description,
category, tags, status (active | inactive), sample_data_enabled, and
allow_evaluations; a body carrying price_cents or derived_pricing is
refused with 400 naming the policy endpoint.
A derived purchase is priced at ceil(rows × per-row rate), clamped to the
envelope. The listing price applies only when there is no per-row rate; the two
are never added. Enabling derivation pricing is atomic: the listing price must be
positive and within the configured listing cap, the minimum and counter floor
must meet the transaction floor, an optional maximum must be at least the
minimum, and a per-row rate is required. The policy endpoint refuses
derivation_pricing.enabled: true without a rows cell
(derivation_row_rate_required) and any derivation_pricing.columns cell
(column_pricing_unsupported; column pricing is not yet supported). It also
refuses a price that would leave an active, open-access, non-sample listing at
$0 (zero_price_open_listing). Public GET /api/v1/listings and
GET /api/v1/listings/{id} responses omit the entire object, including clamps
and buyer adjustments.
GET /api/v1/sellers/{id} is also anonymous. Its response is an explicit public
profile projection and contains exactly id, name, and created_at:
{
"id": "seller-id",
"name": "Acme Data",
"created_at": "2026-08-19T12:00:00Z"
}
The internal account record also has an email address, but email is private
account data and is never returned by this public route. An unknown seller
returns the standard 404 error response.
One exception: a multi-sheet workbook upload to POST /api/v1/seller/datasets
returns the workbook aggregate response (the root listing plus its workbook
block), which does not carry public_visibility. Read the root's publication
state from GET /api/v1/seller/documents (or MCP get_my_listing) instead.
Listing lineage (additive; enabled in the reviewed production deployment)
The public listing projection (GET /api/v1/listings/{id} row shape) and the
seller listing projection (GET /api/v1/seller/documents rows plus the create /
publish write acknowledgements) carry two additive lineage fields when the
server runs with DERIVED_LISTING_LINEAGE_ENABLED=true: derived_from (the real
parent listing when this listing is a derived child) and derivations (the
catalog-visible, published children of this listing). Each node is an explicit
public-safe allow-list of exactly id, title, description, price_cents —
no data_ref, dictionary, seller, or ACL data is copied (the same posture as the
public projection itself).
{
"derived_from": {
"id": "parent-id",
"title": "Parent listing",
"description": "…",
"price_cents": 2500
},
"derivations": [
{
"id": "child-id",
"title": "Derived child",
"description": "…",
"price_cents": 990
}
]
}
Semantics (ADR-216):
- Derived children are real listings. Lineage identity is sourced from the
listing-bound
derived_fromedge between each listing's canonical trust attestations;derivation_manifests.parent_listing_idremains a checked operational pin, not a public identity authority. The projection never uses overlay ids or client-invented slugs. A#listing/{id}link always opens that child's own listing page. derivationslists only catalog-visible, published children. Pending (unsigned), rejected, retired, and sample-class children are excluded by the same public-visibility authority the anonymous catalog applies — they never appear. A parent with no published children has an emptyderivationsarray.derived_fromis omitted when there is no parent (an ordinary listing) or when the parent is not itself publicly readable.- Off by default elsewhere. While
DERIVED_LISTING_LINEAGE_ENABLEDis off, both keys are omitted entirely, so the catalog and seller JSON stay byte-identical to the pre-feature shape.
GET /api/v1/listings pages with limit and offset and returns
{"data":[…],"total":…,"limit":…,"offset":…}. A limit that is absent or not a
positive integer falls back to 20 (the echoed limit reports what you sent,
so use your own requested page size when advancing, not the echoed one).
Each page is assembled per request against a live catalog, so a page can
contain fewer rows than you asked for even when it is not the last page — and
in the limit, none at all. A listing that stops being publicly visible while
that page is being built is omitted from it, and total is a count over the
whole filter rather than of the rows returned.
Page by advancing offset by your requested page size and continuing while
offset < total. Do not treat a short — or empty — data array as the end
of the catalog, and do not derive the page count from len(data); both are
snapshots of a catalog that changes underneath you.
POST /api/v1/search is not a public/anonymous catalog API. It is a
temporary in-wall transport adapter for market-read-proxy, requires a valid
propagated buyer-session OBO token, and is scheduled to move to the mesh-only
listings datastore. API keys and Cognito bearer tokens do not authorize it.
Buyer-agent applications should use the documented buyer-session/MCP workflow,
not call this transport route directly. Its valid per_page range is 1 through
25 and the omitted-field default is 20. As defense in depth, the server maps
non-positive integers to 20 and silently clamps integers above 25, but callers
should not rely on those out-of-contract values. The agent-facing
filters.max_results field remains valid through 50 for deployment
compatibility; values above 25 still produce at most 25 results.
Workbook catalog projection is additive. A workbook is one root listing with
"workbook":{"root_listing_id":"…","sheet_count":2,"is_root":true,"artifact_scope":"data_bearing_worksheets"}.
Worksheets are not separate catalog or purchase targets; workbook.sheets is
empty under the root-only publication contract. sheet_count and the values-
only buyer artifact include data-bearing worksheets only; cover, notes-only,
and chart-only tabs are omitted rather than represented as buyer data.
Listing responses include public catalog metadata plus the always-on materialized menu:
{"menu":[{"offer_id":"offer-...","offer_key":"standard","price_cents":5000,"currency":"usd"}]}
That offer id binds the listing to its immutable materialized base rung. The offer row carries the canonical policy bytes and derived display fields; it is the sole purchase pricing and terms authority. A missing or malformed real row fails closed; the API never synthesizes a virtual standard tier.
When SLICE_ON_DEMAND_ENABLED is on, visible rows from both public listing
endpoints also carry sliceable. A value of false has no
slice_summary; true includes the live buyer-safe row-match policy projection:
{
"sliceable": true,
"slice_summary": {
"kinds": ["row_match"],
"min_price_cents": 100,
"per_row_cents": 1
}
}
Slice metadata is computed only after the parent listing's current visibility check. When slice-on-demand is off, both new keys are absent, preserving the previous response shape. Seller-authored standing segments are ordinary frozen child listings in the catalog; the parent summary does not enumerate them.
While that same flag is on, the unauthenticated license-text route accepts an
active offer_key from the listing's menu (standard for the current base rung).
It returns the exact stored text of record together with the resolved terms,
versions, hashes, price, currency, and offer id:
{
"offer_key": "standard",
"terms": {"...": "..."},
"terms_schema_version": "lts/1",
"dls_version": "tos/5.3-draft",
"terms_hash": "sha256:...",
"price_cents": 5000,
"currency": "usd",
"offer_id": "offer-...",
"rendered_text": "...the operative text...",
"rendered_sha256": "..."
}
Composed tiers also include offer_id. Responses are Cache-Control: no-store;
bind what you reviewed with offer_id, not the human-facing URL. Hidden listings
and unknown, retired, malformed, or wrong-listing keys return opaque 404s.
Missing or mismatched stored evidence fails closed with 500 and no text. The
route is absent when licensing is off. It covers public/open listings only;
whether restricted-listing buyers need a separate authenticated pre-purchase
path, and whether assembled availability is legally sufficient for
purchase-as-acceptance, remain pending counsel review.
In-wall search derives the buyer account and access groups from the signed session token; the request body cannot override self-seller or listing-ACL filters. Each hit is rechecked against current PostgreSQL state, so a stale search-index document cannot expose an ineligible listing.
Buyer images
| Method + path | Purpose |
|---|---|
POST /api/v1/buyer/images |
Push a Dockerfile + context (multipart) |
GET /api/v1/buyer/images |
List your images |
GET /api/v1/buyer/images/{digest}/status |
Build state + egress-test results |
GET /api/v1/buyer/images/{digest}/build-logs |
Build log tail |
DELETE /api/v1/buyer/images/{digest} |
Soft-delete an image |
Account
| Method + path | Purpose |
|---|---|
POST /api/v1/accounts/api-keys |
Mint an API key ({ "name": "..." }; plaintext returned once) |
GET /api/v1/accounts/api-keys |
List key prefixes + metadata |
DELETE /api/v1/accounts/api-keys/{id} |
Revoke a key |
GET /api/v1/accounts/credits |
Credit balance |
Seller endpoints are covered in Selling data. The two advanced
multipart upload paths are POST /api/v1/seller/files (any file format up to
256 MiB with required metadata.content_type, original bytes stored; text-like
files are capped at 200 KB and DOCX packages at 8 MiB; PDF extraction is
best-effort over a bounded prefix, while malformed accepted DOCX packages remain
queued for durable search retry instead of silently publishing metadata-only content; opaque formats
are metadata-only; arbitrary files expose file_scan_status and only clean files
are purchasable/downloadable; a private raw-byte digest blocks byte-identical
re-upload of purchased files) and POST /api/v1/seller/datasets (CSV, XLSX, or
parquet dataset upload; native parquet is streamed to disk and accepted up to
256 MiB and requires a data dictionary, while CSV/XLSX are converted to canonical
parquet projections with an auto-derived dictionary). New original-file intake
preserves the uploaded file as the deliverable, with optional bounded query
projections. See Selling data for staged MCP create/replace and
multipart uploads up to 100 GiB. Malware inspection covers up to the first
2,000,000,000 bytes; scan_coverage.partial discloses an unscanned remainder.
The digest still covers the whole file. A partial scan is not a full-file safety
guarantee.
For a multi-sheet XLSX submitted without the legacy sheet selector, the
dataset metadata JSON must include the current versioned acknowledgement:
{
"title": "FY 2026 operations",
"price_cents": 10000,
"workbook_publication_acknowledged": true,
"workbook_publication_ack_version": "workbook-original-publication-v1"
}
price_cents is the price of one whole-workbook listing. Worksheets are not
sold separately, and the retired worksheet_price_cents field is rejected.
The success response preserves the ordinary root listing fields and additively
includes the server-authoritative original-file summary:
{
"id": "workbook-listing-id",
"status": "active",
"original_file": {
"version": 1,
"content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"size_bytes": 4096,
"source_sheet_count": 2,
"executable_content": true,
"projection_gaps": [{"component": "Forecast", "reason": "workbook_projection_unavailable"}],
"scan_coverage": {"scanned_bytes": 4096, "total_bytes": 4096, "partial": false}
}
}
The deliverable is the original uploaded file, including its formulas, formatting, links, comments, charts, macros, and embedded features. Query projections are optional and do not modify this file. Conversion limits or missing formula caches produce disclosed projection gaps; invalid or encrypted packages remain refused. Executable content is disclosed, never executed by platform services. Binary Excel originals may omit an unknown worksheet count.
CSV, native Parquet, single-sheet XLSX, and XLSX with explicit sheet retain
the singular listing response shape. New intake delivers the original rather
than substituting canonical Parquet.
GET /api/v1/seller/documents likewise returns a workbook as one root row with
the reference-only workbook summary. MCP list_my_listings uses the same root
projection. Original-file byte replacement uses the staged replacement flow,
which appends a new version and preserves prior owners' bytes. Legacy workbook
roots retain their existing mutation restrictions.
Workbook intake and mutation errors use stable machine-readable codes:
| HTTP | code |
Meaning |
|---|---|---|
400 |
workbook_acknowledgement_required |
The multi-sheet publication act was not confirmed. |
400 |
workbook_acknowledgement_version_invalid |
The acknowledgement version is absent or stale. |
400 |
workbook_metadata_inapplicable |
Workbook-only metadata was sent for a non-XLSX upload. |
422 |
worksheet_pricing_retired |
The retired per-worksheet price was supplied. |
409 |
workbook_payout_setup_required |
The seller cannot publish paid workbook products yet. |
409 |
workbook_frozen |
A published root or worksheet mutation is not supported. |
413 |
workbook_request_too_large |
The upload exceeds the dataset request cap. |
422 |
workbook_invalid |
OOXML structure or data is invalid or unsafe to convert. |
422 |
workbook_no_data_sheets |
No worksheet contains publishable tabular data. |
422 |
workbook_limit_exceeded |
A bounded workbook conversion limit was exceeded. |
503 |
workbook_scanner_unavailable |
Raw-source malware scanning could not establish a clean verdict. |
503 |
workbook_capacity_unavailable |
The bounded workbook intake lane is busy; retry later. |
The portal shows and sends the v2 acknowledgement for an XLSX because only the server can safely inspect its worksheets. The server enforces it when multiple data-bearing sheets are found; a single data-bearing sheet remains an ordinary dataset listing and still returns any workbook-format warnings.
Production enables LARGE_LISTING_ENABLED. The REST-only resumable upload
flow is POST /api/v1/seller/uploads →
POST /api/v1/seller/uploads/{upload_id}/parts →
POST /api/v1/seller/uploads/{upload_id}/complete plus DELETE abort. These
routes are absent when the flag is off (the recovery kill-switch). complete answers 400
when a declared-parquet upload fails footer validation (same bounds as
POST /api/v1/seller/datasets), and 409 once any buyer owns the listing:
sold bytes are frozen, and replacement content ships as a new listing rather
than overwriting the object those buyers paid for.
Replace a pending SAT deliverable (seller-human REST)
The authenticated portal can stage a corrected deliverable against a pending SAT-backed Change Request. These four Cognito-only routes are owner-scoped and are not available to seller agents, scoped API keys, MCP, or the public router:
| Method + path | Purpose |
|---|---|
POST /api/v1/seller/change-requests/{id}/replacements |
Start or replay an idempotent replacement candidate and receive a short-lived quarantine upload_url. Body is the closed {idempotency_key,name,content_type,size_bytes,content_sha256} object. |
POST /api/v1/seller/change-requests/{id}/replacements/{replacement_id}/complete |
Verify the exact versioned upload and enqueue safety checks. |
GET /api/v1/seller/change-requests/{id}/replacements/{replacement_id} |
Read the closed owner projection and, after a successful gate, the successor_cr_id. |
POST /api/v1/seller/change-requests/{id}/replacements/{replacement_id}/cancel |
Cancel a pre-swap candidate; replay is idempotent. |
Candidate states are awaiting_upload, queued, scanning, gating,
ready_for_review, refused, timed_out, cancelled, and abandoned.
Until ready_for_review, the predecessor review is unchanged and no listing,
Change Request, offer, payment, ownership, or delivery is created. A
successful gate atomically supersedes the predecessor and exposes exactly one
successor Change Request with fresh pins. The seller must explicitly approve
that successor through the existing approve route; upload and polling never
approve or send an offer. See Selling derived listings
for the portal handoff and failure states.
The routes fail closed with replacement_invalid_json or
replacement_unknown_field (400), replacement_metadata_invalid or
replacement_digest_mismatch (422), replacement_too_large (413), an
opaque replacement_not_found (404), and state/idempotency conflicts or
replacement_already_swapped (409). A transient presign or object check is
replacement_unavailable (503) and does not advance the candidate.
Seller slice authorization (pending counsel review — not yet GA)
Slice-on-demand lets a seller offer buyers derivative sub-slices of a large
dataset listing. The connected portal workflow is documented in
Selling derived listings. Before any slice policy can be enabled, the
seller's account must accept the current Slice Authorization rider (a
versioned legal instrument). Acceptance is account-level — once per account
per rider version — and recorded append-only as compliance evidence. These
routes exist only when
the deployment has SLICE_ON_DEMAND_ENABLED; APP_ENV=prod refuses to boot with
the flag on until the rider is counsel-ratified. Enabled non-production
deployments also require AWS_REGION and TRUST_RECORDING_ENABLED=true; no
model or provenance stub is substituted. Slice model settings are restricted
to reviewed Claude-on-Bedrock profiles, and startup verifies usable AWS
credentials and access to each configured model. The account is always derived
from the authenticated token — never from the request body.
| Method + path | Purpose |
|---|---|
POST /api/v1/seller/slice-rider/accept |
Accept the current rider version for your account |
GET /api/v1/seller/slice-rider/status |
Whether your account has accepted the current version |
POST /api/v1/seller/slice-rider/accept takes an empty body — the rider
version is the server's pinned current version, and the source IP and user-agent
are stamped server-side from the request (never read from the body). It returns
201 with { "rider_version": "slice-rider-v0-draft-e4e4e8ce7beb", "accepted_at": "..." }. Re-accepting the same version is idempotent: it returns 201 with the
original acceptance timestamp preserved and writes no new row.
GET /api/v1/seller/slice-rider/status returns 200 with
{ "rider_version": "slice-rider-v0-draft-e4e4e8ce7beb", "accepted": true, "accepted_at": "..." }. accepted is false when the account has never
accepted, or has accepted only a now-superseded rider version (accepted_at is
then omitted).
Once the rider is accepted, the per-listing slice policy is set and read through these routes (same flag gate; the account is from the token, the listing id is the path resource — never the body):
| Method + path | Purpose |
|---|---|
PUT /api/v1/seller/documents/{id}/slice-policy |
Create/replace the slice policy for one of your parent listings |
GET /api/v1/seller/documents/{id}/slice-policy |
Read the slice policy for one of your parent listings |
PUT body carries the policy: enabled, kinds ([] or ["row_match"]),
key_columns, per_row_cents (US cents/row; fractional/sub-cent allowed,
e.g. 0.5; stored at 1/10,000-cent resolution — nearest micro-USD — and quoted
from the rounded rate), min_price_cents (≥ 100 — a mandatory $1.00 floor), optional
max_price_cents, the bounded-job fields (max_rows_per_slice, max_queries_per_job,
max_jobs_per_buyer_per_day; positive when supplied, with omitted
max_queries_per_job / max_jobs_per_buyer_per_day defaulting to 500 / 3), and
the disclosure_mode / review_mode / watermark_mode enums.
kinds is the buyer-request allow-list, not the seller segment job kind. An
enabled policy with kinds: [] still anchors profile-backed seller authoring but
does not advertise buyer row-match requests. Every enabled policy requires a
positive per_row_cents because it is the fallback when seller-authored
slices omit an explicit price.
max_cumulative_rows_per_buyer is accepted and stored as a reserved policy
value but is not enforced in this beta; cluster-wide, cross-listing, and
cumulative-row metering remain deferred. Any listing_id in the body is ignored
— the path id wins.
Status matrix:
200— policy saved (PUT) or read (GET). ThePUTbody is{ "policy": { … }, "warnings": [ … ] }, wherewarningsare non-blocking cannibalization notices (such as per-row pricing that reconstructs the dataset below the parent price) — surfaced, never fatal.409—{ "rider_acceptance_required": true, "rider_version": "…", "rider_url": "/api/v1/seller/slice-rider/accept" }. The account has not accepted the current rider; nothing is written. Accept the rider (portal/REST) and retry — acceptance is not part of this route.409—{ "error": "platform-created slice listings are immutable", "code": "platform_slice_frozen" }. The target is a materialized slice child, not a seller-editable parent; nothing is written.404— opaque not-found: the listing is not yours, does not exist, or (onGET) has no policy configured. The three are deliberately indistinguishable.400— a validation error:min_price_centsbelow the $1.00 floor, a missing or non-positiveper_row_centson an enabled policy, an unknown key column, amax_price_centsbelow the floor, or an invalid enum or non-positive supplied usage cap. The message names the offending field; no write happens.
Seller segment builder routes
The flagged seller portal's Slicing → Segments tab consumes the following
owner-scoped routes. They are absent when slice-on-demand is off and return the
same opaque 404 for missing, ineligible, or other-seller parents. See
Selling derived listings for the guided profile, preview, and creation
flow.
| Method + path | Purpose |
|---|---|
GET /api/v1/seller/documents/{id}/slice-profile |
Read profile metadata and typed column facets |
POST /api/v1/seller/documents/{id}/slice-profile/refresh |
Queue re-profiling (202; acknowledgement only) |
POST /api/v1/seller/documents/{id}/slice-preview |
Preview a SelectionSpec and receive authoritative counts/sample/edge cases |
POST /api/v1/seller/documents/{id}/slice-agent/compile |
Compile { "instruction": "…" } into the closed filter grammar |
POST /api/v1/seller/documents/{id}/slice-segments |
Queue eager materialization of a reviewed segment |
Both profile routes are authenticated. The account comes only from the verified
principal and {id} only from the path; neither route accepts seller, account,
or listing identity in a request body. Missing listings, other sellers'
listings, unsupported parents, parents without a slice policy, and profiles that
have not completed all return the same opaque 404 { "error": "not found" }.
GET …/slice-profile returns the profile object directly:
{
"profiled_at": "2026-07-15T20:00:00Z",
"profiler_algo_version": "slice-profile-v2",
"row_count": 12,
"columns": [
{
"name": "category",
"type": "text",
"distinct_count": 2,
"values": [
{ "value": "Whiskey", "count": 7 },
{ "value": "Gin", "count": 5 }
]
},
{
"name": "active",
"type": "bool",
"distinct_count": 2,
"values": [
{ "value": true, "count": 9 },
{ "value": false, "count": 3 }
]
},
{
"name": "unit_price",
"type": "float",
"distinct_count": 12,
"min": 10,
"max": 100
}
]
}
Column type is the closed set text | bool | int | float | date. Profile
scalars preserve their JSON type: text and date values are strings, booleans are
JSON booleans, and integer/float bounds are JSON numbers. distinct_count is
present for every column; values is present only for low-cardinality text/bool columns (at
most 200 distinct values); and min / max are present only for numeric/date
columns. The response never contains row keys, row ordinals, an object/profile
reference, a profile hash, or a separate segment enumeration.
POST …/slice-profile/refresh takes an empty body. A successful response is an
exact empty 202 Accepted, written only after the refresh has been durably
marked pending. It does not mean profiling has completed. GET may return
the opaque 404 while that generation is pending; reload or poll GET later
and compare profiled_at. Repeating refresh is safe and does not grant a caller
any additional visibility.
If deterministic parent-data limits or invalid Parquet pages prevent profiling,
the policy remains saved and its response includes a safe warning; the profile
stays at the same opaque 404. Automatic retries stop for that generation.
Replace or repair the parent dataset, then request refresh again.
Preview JSON is { "selection_spec": {…}, "sample_seed"?: "…" }. For an
optional CSV/XLSX/TXT key list of at most 1 MiB, send multipart spec (the same
JSON envelope) plus file; the response may return an opaque queries_ref for
reuse in selection_spec.key_match. Clients must treat that reference as opaque
and invalidate it when the parent, spec, key columns, or file changes.
Invalidation means the client stops reusing the reference; it does not promise
immediate deletion of the corresponding temporary server object.
Multipart preview requires selection_spec.key_match. The server parses and
stores the query list under the authenticated actor, parent listing, and parent
seller, runs the bounded row matcher against the pinned profile generation, and
passes that evidence through the same waterfall evaluator used for
materialization. A queries_ref from another actor or parent is rejected; it is
never a download or presign target. The entire upload part, not merely its parsed
rows, is capped at 1 MiB.
SelectionSpec version 1 is closed: filters contains only typed value_in
(text/bool) or range (numeric/date) entries; optional agent contains the
instruction, compiled filters, and adjudications; optional key_match contains
queries_ref and key_columns; pins.include / pins.exclude are applied last.
Preview responses expose row_count, waterfall, a sample of at most 20 rows,
optional edge cases, and queries_ref. A portal must not display row_key; it is
only an internal reference for pins/adjudications.
Creating sends exactly { "display_name": "…", "selection_spec": {…}, "price_cents"?: 100 } and returns { "job_id": "…" }. The job id acknowledges
queued materialization with 202 Accepted; it does not mean a child listing
is already live. When selection_spec.key_match is present, its opaque
queries_ref must still be live and bound to the same parent, parent seller,
and authenticated acting account that created it during preview. The worker
runs the same bounded matcher and immutable evidence checks used for buyer
row-match, but the resulting seller segment remains a shared standing listing
whose purchase ACL is copied from the parent at child creation. In this beta,
later parent ACL changes are not propagated to an already-materialized segment
or re-checked against the parent during child purchase. Custom children never
appear in the parent's slice summary; they remain discoverable through ordinary
catalog/detail access and purchasable under the stale copied ACL. Disable the parent's
Slice-on-Demand policy before tightening access and do not rely on the parent ACL
change alone to restrict the existing child. The buyer-only
max_rows_per_slice rejection cap does not apply to this seller-authored
segment; a separate platform evidence row/byte ceiling still fails oversized
jobs safely. Once creation returns a job id, the validated queries are
snapshotted into that job's immutable private artifacts, so later
preview-reference expiry cannot invalidate queued work. The optional direct
price must be at least 100 cents and no greater than the lower of the slice
policy maximum and the deployment's listing price ceiling. There is no
price_cents field when the seller leaves price blank; server policy pricing
applies. A successful response without a nonempty job id leaves status unknown
and must not be retried unchanged. There is no segment-offer toggle, profile-row
exclusion mutation, custom-child list, or child deactivation operation in this
builder contract.
All three builder routes return an opaque 404 { "error": "not found" } for a
missing, unsupported, inactive, unprofiled, or other-seller parent. Preview and
compile do not require rider acceptance or payout setup. Segment creation checks
both against the parent listing's seller even when an operator acts on behalf of
that seller; the operator remains the recorded requester. Missing current rider
acceptance returns 409 with rider_acceptance_required, rider_version, and
rider_url. Missing payout setup returns 409 with
code: "payout_setup_required". Invalid selection grammar, price, instruction,
or upload returns 400 and creates no job.
Temporary extraction-model or profile-source failures return 503; callers may
retry those without changing the request.
Seller slice activity and review contract
These owner-scoped routes back the flagged portal Activity and Review tabs.
They are registered only when SLICE_ON_DEMAND_ENABLED is on and remain outside
the production/GA API while the Slice Authorization rider is counsel-gated.
Every resource lookup starts from the account in the verified principal;
foreign and nonexistent resources share the same opaque response. The seller
journey and buyer-visible boundary are explained in
Selling derived listings.
| Method + path | Purpose |
|---|---|
GET /api/v1/seller/documents/{id}/slice-jobs?kind=&state= |
Activity for one owned parent; state=pending_review selects actionable review holds |
GET /api/v1/seller/slice-jobs/summary |
Owner-wide { "pending_review_count": … } for the Listings navigation badge |
GET /api/v1/seller/slice-jobs/{job_id}/match-report |
Full seller-only report for one owned job |
POST /api/v1/seller/slice-jobs/{job_id}/review |
Atomically approve or decline a pending review |
POST /api/v1/seller/slice-children/{listing_id}/deactivate |
Retire one unowned, frozen seller-segment child so a later materialization can use a new price |
The activity response is this closed seller projection:
[{ "job_id": "…",
"kind": "seller_segment | row_match",
"state": "processing | pending_review | active | offered | purchased | not_available | expired | deactivated",
"requester": { "account_ref": "…", "display": "…" },
"spec_summary": "…",
"outcome": "pending | matched | near_miss | unmatched | declined | expired | failed",
"price_cents": 1250,
"created_at": "…",
"has_report": true,
"sales_30d": { "units": 2, "seller_net_cents": 2500 },
"review": { "state": "not_required | pending | approved | declined",
"expires_at": "…" } }]
review.expires_at is optional. seller_segment jobs always use
review.state=not_required; pending, approved, and declined review states are
valid only for row_match jobs. Unknown internal job states and incompatible
kind/review combinations are never serialized. sales_30d is an authoritative
rolling 30×24-hour projection:
standing segments can sell more than once, so clients must not infer units from
job count or net revenue from price_cents. The Activity KPIs sum the supplied
units and seller net by kind. They count requests only from valid created_at
values inside the same inclusive UTC window and exclude future/invalid dates or
unsafe/negative metrics. The pending-review KPI and navigation badge use the
selected parent's closed job list and the owner-wide summary endpoint,
respectively. The account-wide badge is never derived by fanning out across a
possibly truncated listing page.
The match-report response is the seller's full, owner-scoped view:
{ "queries": [
{ "q": "makers mark 46 750ml",
"status": "matched | near_miss | unmatched",
"row_keys": ["…"],
"match_class": "exact | normalized | fuzzy | reasoned",
"confidence": 0.97 }
],
"totals": { "queries": 500, "matched": 412,
"near_miss": 31, "unmatched": 57 },
"rows_selected": 431,
"buyer_disclosure_mode": "aggregate_only | per_query" }
The disclosure mode is snapshotted with the job, so historical UI must not read the parent's current policy to explain what the buyer saw. The portal's CSV is generated locally from this explicit seller projection, neutralizes spreadsheet formula prefixes, and never uploads the report. This DTO must not be reused by a buyer endpoint: it contains the seller's full query list and internal row keys.
Review mode stores the match report, selected-row checkpoint, row count, and
frozen quote first, then places the job on a seller hold before any child is
materialized or an offer becomes visible to the buyer. Review sends exactly
{ "action": "approve" } or { "action": "decline" }; no account, seller,
requester, parent, or job identity belongs in the body. A 200 returns the
canonical seller job projection. The server performs one atomic transition from
review.state=pending; stale or duplicate decisions return 409 and cannot
create two offers or children. Missing, foreign, wrong-seller, and
ineligible-parent resources all return the same opaque 404. After any response
whose outcome might be stale or unknown, clients reload the authoritative list.
Child deactivation accepts no identity or price body. It is available only for
an owned-parent, manifest-backed seller_segment child with no ownership row;
ordinary listings, row-match children, missing ids, and foreign children are the
same opaque 404. Purchase-first or an existing owner returns 409 and retains
the active listing and paid artifacts. Deactivation-first makes the listing
inactive before deleting only the canonical Parquet and XLSX objects. If object
cleanup fails, the inactive state remains safe and repeating the request retries
cleanup. Success is an empty 200; a later seller create produces a fresh job
and listing id rather than mutating
the frozen child.
Seller policy authoring transition
Seller-authored licensing policy is moving to the replacement terms workflow.
The retired offer-authoring routes and inline create-time offer block are not part
of this API. Every listing receives one materialized platform-default base rung at
its listing price, with an immutable offer_id used by the buyer purchase
contract. Existing historical offer rows remain immutable; see Licensing
for the buyer-facing menu and offer binding.
Derived listing sign-off
Opt-in seller capability. These REST routes exist only when
DERIVED_LISTINGS_ENABLEDis on. They are enabled in the reviewed production seller-derivation deployment and absent—not stubbed—when the flag is off. Creation is available only through thecreate_derived_listingMCP tool to a verifiedseller-worker; this applies to both standard and sample children. Creation atomically commits the child, its Change Request, and its referring Inbox delivery. Review is a human act on the Cognito-only Change Request routes, so anamn_agent API key cannot approve, return, or decline. There is deliberately nosign_offMCP tool.
| Method + path | Purpose |
|---|---|
GET /api/v1/seller/change-requests/{id} |
Sole owner-facing review resource. Returns the typed Change Request, complete review pins, lifecycle fields, and a review packet containing the derived listing, parent, artifacts, frozen terms, history, consent, and available containment metadata. Sealed creator/approver identities, live-slot/idempotency coordination, suppression-review internals, and artifact bytes are omitted. |
POST /api/v1/seller/change-requests/{id}/approve |
Cognito-human approve act. Echo the complete CR pin set; stale pins return 409 cr_pins_stale. There is no MCP equivalent. |
POST /api/v1/seller/change-requests/{id}/return |
Cognito-human request-change act. Echo the complete CR pin set and a non-empty note (maximum 4,000 characters). There is no MCP equivalent. |
POST /api/v1/seller/change-requests/{id}/decline |
Cognito-human decline act. Echo the complete CR pin set; an optional note is retained with the terminal disposition. There is no MCP equivalent. |
POST /api/v1/seller/derivation-authorization/accept |
One-time acceptance of the Derivation Authorization instrument (body ignored; version from the pinned loader) |
GET /api/v1/seller/derivation-authorization/status |
{instrument, version, accepted, document_markdown} |
Change Request act bodies fail closed with stable 400 codes: unknown fields
return cr_unknown_field, malformed or trailing JSON returns cr_invalid_json,
an absent pin object returns cr_pins_required, and incomplete or conflicting
pin forms return cr_pins_invalid.
The User Inbox refers to the owning Change Request rather than acting as a
second decision queue. Its act_href resolves
to POST /api/v1/seller/change-requests/{id}/approve, .../return, or
.../decline; every act echoes the CR's
fresh owner projection, read immediately before the write. The portal never
submits the Inbox delivery's cached copy. The act body carries
payload_sha256, primary_artifact_sha256, listing_revision, and
pins_sha256. A SAT-attached request must use the nested pins form and echo
only sat_binding.spec_sha256, coverage_summary_sha256, and
transcript_sha256; SAT bundle coordinates and price are server-owned and are
re-read at apply. Non-SAT requests use the four ordinary pins. Approve may
explicitly carry
accept_derivation_authorization:true to fold the current instrument acceptance
into the same transaction. Return additionally requires a non-empty note of at
most 4,000 characters. The portal never navigates the browser to an API action
URL.
The portal exposes the human portion of these routes under Sell → Inbox.
Selecting a pending child opens its review packet as a Change Request inside
My Listings. The seller can download the owned child with the normal
GET /api/v1/ownership/{id}/download path, approve with the packet's
primary_sha256, request a change, or decline through a two-step confirmation.
The former agent sessions, agent images, and agent marketplace views are not
part of this seller review flow.
The seller-worker MCP request never accepts an account or seller identity.
create_idempotency_key is required, canonical opaque ASCII (maximum 200
bytes), and account-scoped. Repeating it returns the already-created child and
Change Request, including after a concurrent retry loses the unique create
race; it never mints a second child or Inbox delivery. See the
MCP tools reference for the complete creation schema.
derivation_class is standard by default or sample for Sample Data. A
sample create requires price_cents: 0, open ACL, an eligible original
columnar parent, and a row-capped columnar artifact. Seller listing reads expose
sample_data_enabled, owner-only sample_listing_id, and the default-deny
sample_parent_eligible answer used by the portal (false for restricted ACL,
enforcement/Consign, workbook membership, derived ancestry, or an unavailable
eligibility check); PATCH accepts only sample_data_enabled. The public parent projection includes
sample_listing_id only while the complete live-sample predicate holds, and a
public sample child includes sample_of_listing_id. Samples are omitted from
catalog/search lists. Buyers acquire the public child through the ordinary
POST /api/v1/purchases endpoint at zero price and download through
GET /api/v1/ownership/{id}/download; settlement emits sample_acquired
without sale, debit, fee, or payout entries.
Each artifact sets exactly one of text, standard base64, or stage_id;
inline artifacts require name and content_type. The authenticated MCP
envelope is capped before decoding, while the intake service separately
enforces its decoded artifact count and byte limits.
For create_derived_child, the owner Change Request's review packet is
{listing_id, title, price_cents, currency, status, published, parent_listing_id, parent_title?, method, actor_kind, pending_reason?, derivation_note?, primary_sha256?, artifacts[], frozen_terms?, history[], consent} — artifact
metadata only ({role, content_type, size_bytes?, sha256}), never content.
See the MCP tools reference for the field-by-field notes.
A derived listing's content and commercial state are frozen after creation.
For a sidecar-less agent-derived child, the sole lifecycle exception is a
seller-directed, one-way retirement through PATCH /api/v1/seller/documents/{id} with {"status":"inactive"} as the only effective
recognized update. As on other partial updates, unknown properties and recognized
properties set to null are ignored; any other recognized non-null update is
refused. Retirement
removes the child from the pending queue and buyer surfaces and prevents future
purchases. It does not alter or delete content, price, ACL, license terms,
sign-offs, ownership, or prior buyers' download rights. Reactivation, a mixed
retirement-plus-edit request, every other listing edit, set_listing_acl,
the slice policy remain refused with 409 platform_slice_frozen.
Platform-created slice children use their dedicated
unsold-child lifecycle instead. To change a derived listing, create a new one.
Editable derived listings — where an unsigned edit pauses the listing until you
re-approve it — are planned and not yet available.
An approve must name the complete state it adopts. The act echoes
payload_sha256, primary_artifact_sha256, listing_revision, and
pins_sha256 from a fresh owner Change Request read. The server refuses stale
pins with 409 cr_pins_stale and returns the fresh pin set, so the seller can
re-read the review before deciding. A decline atomically records the audit act
and the child's terminal rejected disposition. The rejected child leaves every
buyer surface; rework creates a new child and Change Request. A decline can
never un-publish an approved child.
Amnetic records derivation consent against the general Terms of Service you
accepted at signup, so no separate acceptance is required, the
accept_derivation_authorization flag below is inert, and the
derivation_authorization_acceptance_required refusal does not fire. Both apply
only where a deployment is configured to record the separate per-account
Derivation Authorization instrument instead; the accept and status routes remain
available in either configuration.
Set accept_derivation_authorization: true on the approve to record acceptance of
the current instrument in the same transaction when a version roll has flipped your
consent currency. The remaining refusals are:
| Status | code |
Meaning |
|---|---|---|
409 |
derivation_authorization_acceptance_required |
You have no current-version Derivation Authorization acceptance and did not set the accept flag. The body names the instrument, required_version, and accept_url. |
409 |
listing_inactive |
The child is not active, and a non-active derivation is not publishable. This includes a sidecar-less agent-derived child the seller retired through the inactive-only PATCH, as well as a platform-suppressed child (quarantine or resale adjudication). Reactivation is unavailable in this release. |
409 |
platform_managed_slice |
The child is a platform-managed slice listing — those are flow-approved under your standing policy, never human-signed. |
409 |
already_approved |
You tried to decline a child that is currently approved. Approve is monotone: a decline can never un-publish a signed-off listing. |
404 |
— | Opaque: missing, not yours, and not-a-derivation are byte-identical, so this surface is not an inventory oracle. |
Seller review expiry
Task expiry is a worker transition, not a route. A pending approval bundle carries a review deadline (the review window). When it passes while the bundle is still
pending, the single lifecycle sweeper — the one worker; the approval-bundle component owns no worker and no clock — expires the live bundle (itslive_slotcleared, so the revision is never a live approvable object again) and closes the transaction withclose_reason: review_expired. There is no REST route that triggers expiry: it is scheduled with SAT (enabled in production; 404/absent only if theSELLER_TXN_ENABLEDkill-switch is off), and is the point at which an approvable object becomes permanently un-approvable.
Seller sales stats
GET /api/v1/seller/stats returns seller-level totals and one row for every
listing owned by the authenticated account (including unsold listings):
{
"totals": {
"total_sales": 3,
"gross_revenue_cents": 8000,
"marketplace_fees_cents": -1600,
"net_earnings_cents": 6400,
"balance_cents": 6400,
"withdrawable_balance_cents": 6400
},
"listings": [{
"listing_id": "9dad1234-…",
"sales_count": 3,
"gross_revenue_cents": 8000,
"last_sale_at": "2026-07-14T12:00:00Z",
"offer_breakdown": [{
"offer_key": "internal-training",
"sales_count": 2,
"gross_revenue_cents": 6000
}]
}]
}
offer_breakdown is always present on seller stats. It is
nested per listing, ordered by stable offer_key, and contains only completed
transactions that have a license grant. Amounts are immutable transaction
amounts charged at sale time, not current listing/offer prices. Renewals and paid
upgrades count once; retries, already-licensed, and funded-only outcomes do not.
Retired or re-versioned offer ids with the same key roll up together. A listing
with no grant-backed sales returns []. Pre-evidence transactions remain in the
parent lifetime totals without being assigned a fabricated offer bucket. When
licensing is off, offer_breakdown is absent and the grant query does not run.
Seller agent transactions (SAT) — enabled in production
Enabled in production. These seller-agent-transaction endpoints are live. They 404 by absence only if the recovery kill-switch (
SELLER_TXN_ENABLED) is off; rollback is.github/workflows/flag-rollout.yml.
Seller pricing rules
The platform's deterministic pricing engine prices transactions from an immutable
compiled pricing-rules/v2 document. The document is listing-scoped: it contains
one complete entry per priced listing, including the source-pricing fingerprint,
and has no seller-wide default or fallback. Listing pricing edits are the only
authoring path. The platform normalizes the listing and compiles the seller's
complete active document in the same transaction, reusing the live version when
canonical bytes are unchanged or appending exactly one new immutable version
when they change.
| Route | Meaning |
|---|---|
GET /api/v1/seller/pricing-rules |
Read-only history: the active compiled rule-set version and document, plus the immutable version list (version, content_sha256, created_at). |
There is no full-document write or old-version activation route. To restore historical pricing intent, apply the desired values through the owner-scoped listing pricing update; that reviewed listing change compiles a new immutable version without overwriting unrelated listings.
An active quote must find the exact listing entry. A missing entry returns
ErrNoPricingRulesForListing and no price. The engine also compares the entry's
source_pricing_sha256 with the current owner-scoped listing pricing source;
ErrPricingRulesOutOfSync refuses a quote when they differ, so a missed listing
writer cannot silently price from stale evidence. Pinned historical quotes use
the exact immutable version selected by the Spec and intentionally do not compare
against current listing state.
Seller standing rules
Append-only ledger of what the system has learned "always allow". v1 is write + list only — nothing evaluates it.
| Route | Meaning |
|---|---|
GET /api/v1/seller/standing-rules |
Your rules, newest first, bounded page (?limit=/?offset=). |
Seller task queue
The seller task queue is the seller's home surface: every pending approval bundle waiting on you, oldest-first by review deadline, with whole-set counts so the header is never a guess. Cognito-only (the queue is the human seller's; neither transaction agent has it — both are event-woken, never poll).
| Route | Meaning |
|---|---|
GET /api/v1/seller/tasks?kind=&overdue_only=&limit=&after= |
Your bundle-native negotiation and inquiry tasks, oldest-first by review deadline. Derived approvals are intentionally absent and appear in the Change Request queue. items[] rows: {bundle_id, transaction_id, kind, revision, created_at, expires_at, overdue, due_in_hours, requester_label, buyer_type, is_redraft}. requester_label is the buyer's declared legal name, or else a neutral "Buyer " placeholder — never their account name or email. Keyset-paged via an opaque ?after= cursor; kind (negotiation_escalation/inquiry_escalation) and overdue_only=true narrow the page. |
Approval bundles (seller review)
The approval-bundle surface is the human seller's review boundary. It is
Cognito-only, owner-scoped, and 404-by-absence only if the
SELLER_TXN_ENABLED recovery kill-switch is off:
| Route | Meaning |
|---|---|
GET /api/v1/seller/bundles/pending |
Pending approval-bundle revisions, oldest review deadline first. |
GET /api/v1/seller/bundles/{id} |
One bundle-native negotiation or inquiry packet. Derivation-approval bundles return the same opaque 404 as a missing or foreign bundle; their sole owner review resource is the Change Request. Attachment object refs remain server-only and ready attachments receive short-lived URLs. |
POST /api/v1/seller/bundles/{id}/act |
Bundle-native negotiation and inquiry exits only. A derivation_approval bundle is read support for its owner Change Request and cannot be acted on here. |
Derived approval is a human act through the owner Change Request; neither seller agent role can perform it. Approval creates the offer from the platform pricing engine. It does not claim that a buyer accepted or paid until the later buyer steps succeed.
Buyer transaction status
The buyer transaction-status surface is the buyer's read over their own seller agent transactions: where each stands, whose turn it is, the one live deadline, and (once an offer exists) their current offer. It is the read counterpart to the write/act edges and the thread surface.
| Route | Meaning |
|---|---|
POST /api/v1/sellers/{seller_id}/transactions |
Open or replay a transaction with a public seller. Cognito ID token + Content-Type: application/json; exact body {"idempotency_key":"…"}. The key is 1–200 ASCII letters/digits or ._:-. Buyer identity is sealed from the principal and seller identity from the path; identity fields anywhere else are rejected. Same buyer/key/seller returns the original row; a different seller for that key is opaque 409 idempotency_conflict; unavailable/self seller is opaque 404; the open cap is 429. The aggregate, seller_txn_opened event, and replay binding commit atomically. |
GET /api/v1/transactions/{id} |
Your own transaction's status detail. |
GET /api/v1/transactions |
Your own transactions, newest first. |
Both are authenticated with your Cognito ID token and are owner-scoped: a
transaction that is not yours — or an id that does not exist — returns the
same opaque 404/not_found, so you can never probe whether another buyer
has an open negotiation.
The buyer thread and payment routes are part of the same production-on SAT
surface (404 only if the SELLER_TXN_ENABLED kill-switch is off):
| Route | Meaning |
|---|---|
POST /api/v1/transactions/{id}/thread |
Post a buyer-authored message (body and attachment caps are refused, never truncated). |
GET /api/v1/transactions/{id}/thread |
Read the buyer's own fenced, keyset-paged thread. |
GET /api/v1/seller/transactions/{id}/thread |
Seller-side read of the transaction thread; identity is the authenticated seller. |
GET /api/v1/transactions/{id}/payment |
Read the current offer, obligation, and settlement attempts. |
POST /api/v1/transactions/{id}/offers/{offer_id}/accept |
Accept the current offer and create the payment obligation. |
POST /api/v1/transactions/{id}/offers/{offer_id}/decline |
Decline the current offer. |
POST /api/v1/transactions/{id}/payment/abandon |
Abandon an accepted obligation. |
POST /api/v1/transactions/{id}/payment/wallet |
Pay an open obligation from wallet credit. |
POST /api/v1/transactions/{id}/payment/card |
Start card payment for an open obligation. |
All buyer reads/acts derive identity from the verified Cognito principal and return an opaque 404 for another buyer's transaction. Payment and delivery are not implied by an approval or an offer; the state and settlement records are the source of truth.
System-authored entries carry author_kind: "system", plus the closed
system_template_id and its system_params object. The replacement notice
template status.deliverable_updated_pending_review is emitted only after a
successful seller deliverable swap; it tells you that the earlier draft was
never approved or offered and that the updated deliverable awaits seller
approval. It contains no filename, digest, scan result, or private coverage.
Status detail fields:
| Field | Meaning |
|---|---|
state |
The storage state: converging, drafting, seller_review, offered, awaiting_payment, delivered/closed, or closed_revivable. |
state_label |
Server-rendered label for the state (e.g. Awaiting payment). |
whose_move |
Whose turn it is: requester (you), owner (the seller), platform (system working), or none (terminal). |
deadline |
The single live clock of the current state (its kind and expires_at), absent when no clock is running. |
offer |
Your current offer: id, price, currency, expiry, delivery_format, immutable terms_ref, the bounded coverage summary, frozen gap options, and counter round. Absent before an offer exists (nothing is disclosed pre-offer). |
thread |
The keyset thread cursor: latest_entry_id (ULID of the newest entry, or empty) and unread_hint (0 until read-tracking ships). |
revive_available |
True when the state is closed_revivable and can be revived. |
Everything is server-rendered from transaction state — state_label, whose-move,
and the deadline come from one platform vocabulary, so the buyer portal, seller
portal, and email never disagree about what a state is called.
Buyer counter + revive (SAT OF9 / T-9)
The buyer counter and revive acts on a seller-agent transaction. Both are Cognito-only buyer edges — identity comes from your verified token, never the body — and both are absent (404) while the feature flag is off. The offer/tx ids come from the path.
| Method + path | Purpose |
|---|---|
POST /api/v1/transactions/{id}/offers/{offer_id}/counter |
Offer a strictly-lower price for the current offer (a counter at or above the offer price is refused; the transaction-wide counter cap is enforced). |
POST /api/v1/transactions/{id}/revive |
Re-open an expired (closed_revivable) transaction: re-issue the offer under the standing seller approval (0 pings) when every gate passes, else re-enter drafting for a fresh offer. |
POST …/counter takes
{ "amount_cents": 800, "message": "I'd pay 800 for this." }
An in-rules counter re-issues the offer at the countered price under the
standing approval (the transaction stays offered); an out-of-rules counter
withdraws the live offer, opens a negotiation_escalation bundle, and moves
the transaction to seller_review. The offer_id path segment must equal the
transaction's live open offer: a stale or wrong offer_id counters nothing and
is refused (409, the same pinning posture as accept), so a client can never
silently target a different offer than the one it named. Both acts return the
post-act transaction row:
{
"transaction_id": "…",
"state": "offered",
"offer_round": 2,
"out_of_rules_counter_count": 0,
"current_offer_id": "…"
}
Refusals: a counter at/above the offer price, an invalid amount/currency, or an
over-length message → 422 invalid_counter; a non-owner or missing transaction
→ opaque 404 not_found; a capped transaction → 429 counter_cap_reached; a
transaction not in offered → 409 transaction_state_conflict. POST …/revive takes an empty body and returns the same post-act projection
(state offered on a 0-ping re-issue, drafting on the redraft path). A
revive re-issues the offer at the old (expired) offer's price — never a
fresh re-quote — and only when every hard gate passes (bundle approved, child
present and unretired with its artifact unchanged, ACL account-mode to you,
terms still valid under the current vocabulary); any gate failure instead takes
the drafting redraft path.
Buyer license read
Every purchase records an append-only license grant — the evidence of exactly what license you bought and under which versioned legal instrument. Buyers can read that evidence back. These surfaces are read-only and mint no purchase handle. Counsel review still governs how composed terms are interpreted; it does not hide the routes. Identity is always the authenticated buyer — never the request.
| Method + path | Purpose |
|---|---|
GET /api/v1/ownership/{listingId}/license |
The rendered license text-of-record for a listing you own |
GET /api/v1/ownership/{listingId}/license returns the read-time
hash-verified operative license text for the newest grant you hold on the
listing. Before serving, the platform re-hashes the stored text and confirms it
matches the hash pinned into your grant at purchase time; if the stored evidence
is missing or has drifted, the read fails closed with a 500 and serves no
text (it never substitutes or re-renders a fallback). It returns 200 with:
{
"grant": {
"grant_id": "…",
"offer_key": "standard",
"terms": { "...": "..." },
"terms_schema_version": "lts/1",
"dls_version": "tos/5.3-draft",
"granted_at": "…",
"instrument_status": "pre_ratification"
},
"rendered_text": "…the full operative license text of record…",
"rendered_sha256": "…",
"dls_sha256": "…",
"terms_hash": "…"
}
instrument_status is pre_ratification while purchases are governed by the ToS
draft (before the Data License Schedule is counsel-ratified) and is omitted once a
ratified instrument governs the grant. The grant view is deliberately token
free — it is proof of what you already hold, never a handle to buy again. A
listing you do not own — or one that does not exist — returns an opaque 404
(the endpoint is not an existence oracle).
When the flag is on, two existing surfaces also carry license fields:
GET /api/v1/purchases/{id}addsoffer_keyandterms_hash(the licensing tier purchased and the hash of its composed terms) for transactions that minted a grant. Both are omitted when license terms are off, so the wire is unchanged on the dark path.GET /api/v1/ownershipadds a per-rowlicenseblock:{ "newest": {…grant view…}, "grants": [ …newest-first history… ] }for listings you hold a grant on, or{ "implied": true }for a listing you own with no recorded grant (the implied standard-license display convention — no grant row is synthesized). The operative scope of that implied "standard license" is the ToS default and is pending counsel confirmation (which is why this whole surface is not yet GA).
The CLI wraps the render endpoint as amnetic buyer license <listing-id> (add
--json for the raw payload with all hashes).
Seller Inbox and Change Requests
When INBOX_ENABLED and CHANGE_REQUEST_ENABLED are enabled, Cognito sellers
can read referring review deliveries at GET /api/v1/inbox and
GET /api/v1/inbox/{delivery_id}, then mark or archive them with the matching
POST .../read and POST .../archive routes. A derived-listing delivery carries
an authorized subject projection only:
{"subject":{"change_request_id":"…","target_listing_id":"…","state":"pending_approval","pins":{"payload_sha256":"…","primary_artifact_sha256":"…","listing_revision":0,"pins_sha256":"…"}},"act_href":"/api/v1/seller/change-requests/…"}
Unauthorized or terminal subjects are tombstoned (subject omitted). The
Change Request resource is GET /api/v1/seller/change-requests/{id}; actions
are separate POST .../approve, .../return, and .../decline routes. Each
body must echo the complete pins object. Non-SAT requests may instead use the
equivalent flat pin fields; a SAT request must echo nested sat_binding with
only spec_sha256, coverage_summary_sha256, and transcript_sha256.
Legacy {action, artifact_sha256} bodies are rejected. return requires a
trimmed, non-empty note no longer than 4,000 characters. Approve is retry-safe
for an identical applied pin echo; stale pins return 409 cr_pins_stale with
fresh_pins. Return and decline atomically archive the Inbox delivery and mark
the unreleased child rejected; a revision is always a new child and Change
Request. A direct archive of a delivery whose Change Request is still pending is
refused with 409 inbox_action_pending while the CR is either draft or
pending_approval; act on the Change Request so its review
state and Inbox visibility cannot diverge.
Inbox pages are stable keyset pages: items are ascending by delivery ULID and
after returns items strictly after that cursor. limit is clamped to the
server page maximum, and total_unread covers the whole live mailbox rather
than only the returned page. Native messages project a bounded body preview;
referring deliveries project their preview and action through the owning
subsystem's authorization, with unreadable subjects rendered unavailable.