# MasterDB Developers — the complete documentation Generated from https://docs.masterdb.ai on every build. The API reference is not repeated here; the OpenAPI 3.1 documents are: https://docs.masterdb.ai/reference/retrieval.json, https://docs.masterdb.ai/reference/public.json, https://docs.masterdb.ai/reference/business.json, https://docs.masterdb.ai/reference/mcp.json. --- # MasterDB for developers > Integrate with MasterDB as an AI company that retrieves what businesses publish, or as a business, or its integrator, that publishes it. Source: https://docs.masterdb.ai/ ## Two audiences, one set of rules ### For AI companies Search five collections — products, Business & Brand files, events, jobs and updates — with your own query, filter and sort. Fetch any record exactly as the business sealed it. Every request is signed with your key; every response carries a receipt signed by MasterDB. Start with the [quickstart](/ai-companies/quickstart/). ### For businesses and integrators Publish through the Business Portal, where a person seals each save with a passkey, or push from your own systems with an integration key under a mandate that person sealed. Nothing is published unsealed. Start with the [overview](/businesses/overview/). ## What holds on every request - **Sealed, or not published.** Every record is signed over the exact bytes stored. A business that holds its own key seals with a key MasterDB never holds; a business that uses hosted signing has MasterDB sign for it after a person at the business confirms, and a signed [key custody statement](/reference/key-custody/) says which. - **Signed requests, signed receipts.** An AI company's key signs every request (RFC 9421); MasterDB signs a receipt naming what it served, to whom and when. - **MasterDB never chooses the order.** A search has no default sort; the caller writes one. There is no ranking to buy. - **Blocking is invisible.** A business may block an AI company's group. The blocked company sees fewer rows, and nothing else. - **Verifiable without an account.** Certificates, keys, projection specifications and the transparency log are public; the [verifier libraries](/ai-companies/verifier-libraries/) check everything offline. - [Concepts](/concepts/): Seals, receipts, the AI policy, blocking, billing and verification in plain words. - [The sandbox](/sandbox/): A fictional corpus that behaves exactly like production, for testing both sides of an integration. - [API reference](/reference/): Generated from the OpenAPI 3.1 documents: retrieval, verification, business. - [Security model](/threat-model/): What MasterDB guarantees, what it does not claim, and how each guarantee can be checked. Everything on this site is also available as Markdown: add `.md` to any page's path, or read [`llms.txt`](/llms.txt) and [`llms-full.txt`](/llms-full.txt). --- # Concepts > The ideas every integration meets — parties, records, seals, certificates, rows, receipts, the AI policy, blocking, billing and verification — in plain words. Source: https://docs.masterdb.ai/concepts/ ## Parties and identifiers A **business** and an **AI company** are both *parties*: legal entities that MasterDB has verified. Each has a permanent public identifier — `business_uuid` or `ai_company_uuid` — that never changes and identifies no person. AI companies belong to an **AI group** (a company and its affiliates); a business blocks a group, never a single key. People act for a party through **grants** (roles such as owner, admin, catalogue manager or developer). Machines act through **keys**: an AI company's *retrieval keys*, a business's *integration keys*. No key is ever shown to anyone but its holder, and no response names a person. ## Records A business publishes five types of record: | Type | What it is | Id prefix | |---|---|---| | `products` | an item or service it sells, with a price per country | `mdb_` | | `business_files` | its Business & Brand file: who it is, where it trades, how to reach it, and how it wants to be represented | `bf_` | | `events` | something happening at a time and place | `evt_` | | `jobs` | a vacancy | `job_` | | `updates` | news it wants known, with a date it stops being relevant | `upd_` | A sixth, `ai_policy`, is the business's own sealed record of what it permits AIs to do with its data and the contexts it does not want it used in (see [AI policy](#ai-policy)). Every record carries a `schema` member naming its type and format version, such as `"schema": "masterdb/products/1"`, so bytes sealed today can be read correctly years from now. A product's id is derived from the business's public identifier and its own product id: the same product pushed twice is an update, never a duplicate. ## Seals A **seal** is the business's signature over the exact bytes of a record, in a [DSSE](https://github.com/secure-systems-lab/dsse) envelope. It says *who* published *these bytes* and *when*, and it names the business's certificate and the AI policy version in force. MasterDB never re-serialises a sealed record: what you fetch is byte for byte what was sealed. A business seals in one of three ways: - **Path B — a person in the Business Portal** seals each save with a passkey. The browser builds a canonical form of the draft, hashes it, and the person's passkey signs it. - **Path A — the business's own system** seals a batch with its own integration key, under a *mandate* a person sealed with their passkey. One signature covers the Merkle root of the batch; each record carries its own inclusion proof. - **Hosted signing** — for a business that holds no key of its own, MasterDB holds a signing key for it and signs each publish only after a person at the business has confirmed it with a one-time code. MasterDB's signed [key custody statement](/reference/key-custody/) says, for every key, whether it is `hosted` or held by the business (`self`). A seal proves who published the bytes and when. It does not prove the bytes are true. ## Certificates When MasterDB verifies a business or an AI company it issues a **certificate**: a small document signed by MasterDB's issuance key naming the legal entity, its jurisdiction (the country), that MasterDB verified it and since when, and its status (`active`, `closed`, `suspended`, `revoked` or `withdrawn`). Certificates are public at `GET /v1/certificates/{uuid}` and never expire; a change of status is a new issuance, never an edit, and every issuance is recorded in the transparency log. Every status takes effect from its `status_effective_from`, and a seal is judged against the certificate in force when it was sealed, so records sealed before a change keep verifying: | Status | Meaning | |---|---| | `active` | MasterDB stands behind the verification. | | `suspended`, `closed` | The business is suspended or closed. Nothing new is served; its history stays valid. | | `revoked` | A key was compromised. Seals made from the effective date are invalid. | | `withdrawn` | MasterDB withdrew its attestation, for example because it reversed its approval (`status_reason: approval_reversed`). This says nothing about the business's keys. Seals made from the effective date no longer stand, and verifiers refuse them as `certificate_withdrawn`. Earlier seals stay valid. Nothing is served until the business is verified again. | The certificate says who the subject is, its country, that MasterDB verified it and since when, and its status — nothing more. Its statement is the status and the date, for example "Verified by MasterDB on 5 October 2026." ## Rows and records Search does not return records. It returns **rows**: short, flat projections of records (the name, the price in each country, the category, the dates) made by a published, versioned *projection specification* and signed by MasterDB's projection key. Each row carries its origin: `adl_origin` (the hash of the record's bytes), `adl_proj` (which projection made it) and `adl_row_sig`. A row can be answered from directly, or you can fetch the record for everything the business sealed. ## Receipts Every search and every fetch returns a **receipt**: MasterDB's signed statement of what it served, to whom, when, in which region, and — row by row — which version. Your request signature is your statement that you asked; the receipt is MasterDB's that it answered. A dispute about "you served me the old price" is settled by the receipt alone. Receipts are folded, minute by minute, into the transparency log. See [Receipts](/ai-companies/receipts/). ## AI policy A business's **AI policy** is a set of named booleans it seals like any other record: what an AI may do with its data (`cite_as_source`, `definitive_source`, `prefer_over_inference`, `include_in_recommendations`, `quote_policy_verbatim`, `prices_indicative`, `state_publish_date`), the contexts it does not want its data used in (ten **blocked contexts**, `bc1`–`bc10`: adult content, alcohol, crime, death and disaster, weapons, gambling, mental health, politics, regulated advice, tobacco and drugs) and which actions it permits (`answer`, `quote`, `reserve`, `purchase`, `contact`, `hand_to_human`). Every row carries the bits in force for its business; every fetch carries the sealed record. The **AI-company Terms** are the agreement every AI company signs; the use conditions — once, in one chat, no training, no caching — are stated there once and travel by version. See [AI policy](/ai-companies/ai-policy/). ## Blocking A business may block an AI group. From then on, in every region, for every key in the group: its rows are absent from searches, and its records answer a fetch exactly as a record that never existed. **Nothing tells the blocked company**: no field, no count, no error, no figure in the AI Portal. See [Blocking is invisible](/ai-companies/blocking/). ## Billing An AI company is billed per query, as MasterDB observed it: a search that returns at least one row, or a fetch that returns a record. Zero-result searches, not-found fetches, refusals and MasterDB's own failures are never billed. Each signed request is billed once, whichever regions answered it. See [Usage and billing](/ai-companies/usage-and-billing/). ## MasterDB never chooses There is no ranking in MasterDB. A search must name its sort (`sort_by`), and a caller that wants relevance writes `_text_match:desc`. The caller directs the query; the business directs its representation; MasterDB chooses nothing in between. Paid placements exist (sponsored items and ads), are always marked, and never change the order of a search. ## Verification without trust Everything needed to check what MasterDB serves is public and fetchable without an account: the signed key set (`/.well-known/keys`), certificates, each business's public sealing and integration keys beside its certificate (`GET /v1/certificates/{uuid}/keys`, signed by MasterDB's statement key), projection specifications, the transparency log's checkpoints and proofs. The [verifier libraries](/ai-companies/verifier-libraries/) (TypeScript and Python, Apache 2.0) check seals, rows, receipts, certificates and the chain of MasterDB's keys to pinned trust anchors, offline. --- # The sandbox > A complete second environment over a fictional corpus, identical to production in every envelope, receipt, seal and error, for testing both sides of an integration. Source: https://docs.masterdb.ai/sandbox/ The sandbox is a complete second environment of MasterDB: its own key register, its own trust root and transparency log, and the same software as production. It serves a **fictional corpus** — businesses, AI companies and records of every type — published through the real publish path and sealed under sandbox certificates, so every sandbox record verifies with the production verifier and every response is real in shape. | | Sandbox | Production | |---|---|---| | API | `https://sandbox.api.masterdb.ai` | `https://api.masterdb.ai` | | Verification API | `https://sandbox.api.masterdb.ai` | `https://verify.masterdb.ai` (and the same paths on `api.masterdb.ai`) | | Trust anchors | the sandbox root, an explicit opt-in in every verifier | the production root, pinned | | Log origin | `masterdb.ai/log/sandbox/v1` | `masterdb.ai/log/v1` | Every code sample on this site runs against the sandbox corpus. ## What differs from production Stated once, here: - **No verification.** A sandbox key needs no verification of your company, and no AI-company Terms. A business's sandbox integration key needs no verification either. - **Signed requests, as in production.** Every sandbox request is signed with your key ([Signing requests](/ai-companies/signing-requests/)), and until the sandbox has seen your key, it carries the key's grant ([below](#taking-a-sandbox-key-in-the-ai-portal)). - **The same rate limits as production** ([Rate limits](/ai-companies/rate-limits-and-errors/#rate-limits)). - **Billing is computed and shown, never invoiced or paid out.** - **Ads are served from fictional budgets**, with real tokens and no money ([ads and sponsored items](/ai-companies/ads/)). - **A `sandbox: true` member** on every receipt and every certificate. - **Separate keys and roots.** A sandbox key, certificate or signed token is never accepted by production: the issuer differs, and a request signature covers `@authority`, so a request signed for `sandbox.api.masterdb.ai` cannot be replayed to `api.masterdb.ai`. Everything else is identical: envelopes, receipts, seals, AI policies, blocking, the projection specifications, error shapes, and the SDKs pointed at a different base URL. ## Taking a sandbox key in the AI Portal An AI company takes a sandbox retrieval key in the **AI Portal**, the same portal it uses for everything else; a business takes a sandbox integration key in the **Business Portal**. There is no separate sandbox portal or sign-in. What the portal needs from you, for an AI company: 1. **A person who may manage retrieval keys** (`owner`, `admin` or `integration_manager`), signed in with a **passkey**. 2. **Your company set up as an AI company in the portal.** 3. **The public half of your key**, as a JWK, and a label. It does not need your company to be verified, and it does not need the AI-company Terms. The portal answers with the key's id (the RFC 7638 thumbprint of the public JWK), `sandbox_api_origin` (where the sandbox API is) and a `sandbox_key_grant` (`mdb_sbxk1.…`): MasterDB's signed statement of the key and your company, which holds no secret. The list of your sandbox keys returns the grant again. A revoked sandbox key stops working in the sandbox within about a minute. ### Sending the grant: `MDB-Sandbox-Key` The sandbox does not know your key until a request carries its grant. **Send it as the `MDB-Sandbox-Key` header.** The first signed request that carries it registers your company and the key in the sandbox, and is then verified like any other; without it, that first request is refused `401` `key_unknown`. The header is not part of the signature. A key revoked in the portal stays refused in the sandbox, with or without its grant. Production ignores the header, and a sandbox key is never registered in production. ```http POST /v1/search HTTP/1.1 Host: sandbox.api.masterdb.ai Content-Type: application/json MDB-Sandbox-Key: mdb_sbxk1.eyJ2IjoxLCJraW5kIjoicmV0cmlldmFsIn0.… Signature-Input: sig1=("@method" "@authority" "@path" "content-digest");created=…;keyid="…";tag="mdb-retrieval" Signature: sig1=:…: ``` With the TypeScript SDK, pass the grant once and every request carries it: ```ts const client = createRetrievalClient({ baseUrl: SANDBOX.retrieval, signer, sandboxKeyGrant }); ``` `createBusinessClient` takes the same option for a business's sandbox integration key. With the Python signing helper ([Signing requests](/ai-companies/signing-requests/#python-the-signing-helper)), pass it to the auth hook (the Python quickstart does): ```py client = httpx.Client( base_url=SANDBOX_API, auth=MasterDBAuth(load_key("retrieval-key.pem"), sandbox_key_grant=grant), ) ``` The CLI sends it on the signed fetch of `masterdb verify --url`: ```sh masterdb verify --url https://sandbox.api.masterdb.ai/v1/records/mdb_… --sandbox-key-grant "$GRANT" --sign-key test-key.jwk --sandbox ``` The Postman collection has the header on its sandbox requests: set its value to your grant. The self-hosted MCP server takes it as `sandbox_key_grant` in its configuration (or `MASTERDB_SANDBOX_KEY_GRANT`) and sends it on every request; it is accepted only with `"environment": "sandbox"`: ```json { "version": 1, "environment": "sandbox", "key": { "file": "retrieval-key.jwk" }, "sandbox_key_grant": "mdb_sbxk1.…" } ``` ## The corpus The corpus is generated from a fixed seed, so every reset reproduces it byte for byte and **every identifier quoted in these documents exists**. Names are unmistakably fictional (every legal name ends in "(Sandbox) Ltd" or "(Sandbox) Inc"), and every URL is on `*.sandbox.masterdb.ai`, so a link followed from a record lands somewhere harmless. The corpus has deliberate variety: businesses in the US, Ireland, the UK, Canada and Australia across many verticals; catalogues published through both the portal and pushes; some businesses with `purchase` and `reserve` permitted and some with `quote` off; several Business & Brand files per country; and **some businesses that block the sandbox AI company's group**, so that blocking can be tested from the AI side. A few of the identifiers the guides use: | What | Identifier | |---|---| | Garnet Mill, a fictional Irish business that publishes through the portal | `6c1658dd-fa53-4f12-8a18-6eee69f5ca99` | | One of its products | `mdb_xmomas3i3kzkdwyqpfoxou5rea` | | Ember Spindle, a fictional business in the US and GB that pushes its catalogue | `b809c8bd-8e0e-4a4b-92cb-e7f28cd36b01` | | Saffron Mill, which blocks the sandbox AI company's group | `0c148394-9635-498d-84bc-f4a785a948c5` | | The sandbox AI company, "MasterDB Sandbox AI (Sandbox) Inc" | `fc8bb07a-86ab-4c08-8e0b-0b2fca555c44` | ## Lifecycle The corpus is republished nightly: the same ids and bytes, as new versions. What you create — your own sandbox business, keys, mandates, pushes, receipts — is kept for 90 days from its last use, with a notice 14 days before deletion. ## Moving to production Production is a separate path in the same portal, with its own key: your company is verified, accepts the AI-company Terms, and then registers a production key ([Keys](/ai-companies/keys/)). Your sandbox keys stay and keep working in the sandbox, and are never accepted by production. A sandbox key is never turned into a production one. A business moves to production the same way from the Business Portal. --- # Quickstart for AI companies > A first signed search and fetch against the sandbox, in TypeScript or Python, with a sandbox key taken in the AI Portal. Source: https://docs.masterdb.ai/ai-companies/quickstart/ 1. **Make a key.** An Ed25519 key pair, made where it will live. The private half never leaves your systems; MasterDB only ever sees the public half. ```sh openssl genpkey -algorithm ed25519 -out retrieval-key.pem openssl pkey -in retrieval-key.pem -pubout -out retrieval-key.pub.pem ``` 2. **Take a sandbox key in the AI Portal.** Someone with the `owner`, `admin` or `integration_manager` role, signed in with a passkey, registers the public half of the key as a JWK, with a label. A sandbox key needs no verification and no AI-company Terms. See [The sandbox](/sandbox/#taking-a-sandbox-key-in-the-ai-portal) for what the portal needs, and [Keys](/ai-companies/keys/). ```sh node -e "const c=require('node:crypto'),f=require('node:fs');console.log(JSON.stringify(c.createPublicKey(f.readFileSync('retrieval-key.pub.pem')).export({format:'jwk'})))" ``` The answer names the key's id (the RFC 7638 thumbprint of the public JWK) and carries a `sandbox_key_grant` (`mdb_sbxk1.…`). **Keep the grant**: the sandbox does not know your key until a request carries it in the `MDB-Sandbox-Key` header. The grant holds no secret, and the list of your sandbox keys returns it again. 3. **Install the SDK and the verifier.** ```sh npm install @masterdb/client @masterdb/verifier # TypeScript, Node 24 or later pip install httpx masterdb-verifier # Python 3.10 or later ``` In Python, requests are signed by one small file, `masterdb_signing.py`, which the Python samples import. See [Signing requests](/ai-companies/signing-requests/). 4. **Search, then fetch.** Every request is signed; every search names one collection, exactly one country, and a sort. ```typescript title="samples/typescript/quickstart.ts" // Quickstart: one signed search and one fetch against the sandbox. // // MASTERDB_KEY_FILE your retrieval key's private half (PKCS #8 PEM); its public half is the sandbox key you took in the AI Portal // MASTERDB_SANDBOX_KEY_GRANT optional; that key's sandbox_key_grant (mdb_sbxk1.…), needed until the sandbox has seen the key once // MASTERDB_API_URL optional; the sandbox by default import assert from 'node:assert/strict'; import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { SANDBOX, createEd25519Signer, createRetrievalClient } from '@masterdb/client'; const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_KEY_FILE as string))); const sandboxKeyGrant = process.env.MASTERDB_SANDBOX_KEY_GRANT; const client = createRetrievalClient({ baseUrl: process.env.MASTERDB_API_URL ?? SANDBOX.retrieval, signer, ...(sandboxKeyGrant ? { sandboxKeyGrant } : {}) }); // One collection, exactly one country, and a sort you choose: MasterDB never picks an order for you. const search = await client.POST('/v1/search', { body: { collection: 'products', q: 'rook', query_by: ['product_name'], filter: { all: [{ country: 'US' }, { availability: 'available' }] }, sort_by: 'price_amount:asc', limit: 5, }, }); if (search.error) throw new Error(`search refused: ${search.error.code} ${search.error.detail ?? ''}`); for (const row of search.data.rows) { console.log(row.record_id, row['product_name'], row['price_US'], row['price_currency_US']); } assert.ok(search.data.rows.length > 0, 'the sandbox corpus has products in the US'); assert.ok(search.data.receipt, 'every search carries a signed receipt'); // Fetch the first row's full record: the bytes exactly as the business sealed them, the seal, and its AI policy in force. const first = search.data.rows[0] as (typeof search.data.rows)[number]; const fetched = await client.GET('/v1/records/{record_id}', { params: { path: { record_id: first.record_id } } }); if (fetched.error) throw new Error(`fetch refused: ${fetched.error.code}`); console.log(fetched.data.type, fetched.data.record['product_name'], 'AI policy version', fetched.data.ai_policy.version); assert.equal(fetched.data.record_id, first.record_id); ``` ```python title="samples/python/quickstart.py" """Quickstart: one signed search and one fetch against the sandbox. MASTERDB_KEY_FILE your retrieval key's private half (PKCS #8 PEM); its public half is the sandbox key you took in the AI Portal MASTERDB_SANDBOX_KEY_GRANT optional; that key's sandbox_key_grant (mdb_sbxk1.…), needed until the sandbox has seen the key once MASTERDB_API_URL optional; the sandbox by default """ import os import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, load_key grant = os.environ.get("MASTERDB_SANDBOX_KEY_GRANT") client = httpx.Client( base_url=os.environ.get("MASTERDB_API_URL", SANDBOX_API), auth=MasterDBAuth(load_key(os.environ["MASTERDB_KEY_FILE"]), sandbox_key_grant=grant or None), ) # One collection, exactly one country, and a sort you choose: MasterDB never picks an order for you. search = client.post( "/v1/search", json={ "collection": "products", "q": "rook", "query_by": ["product_name"], "filter": {"all": [{"country": "US"}, {"availability": "available"}]}, "sort_by": "price_amount:asc", "limit": 5, }, ) if search.status_code != 200: raise SystemExit(f"search refused: {search.json()['code']}") result = search.json() for row in result["rows"]: print(row["record_id"], row["product_name"], row["price_US"], row["price_currency_US"]) assert result["rows"], "the sandbox corpus has products in the US" assert result["receipt"], "every search carries a signed receipt" # Fetch the first row's full record: the bytes exactly as the business sealed them, the seal, and its AI policy in force. first = result["rows"][0] fetched = client.get(f"/v1/records/{first['record_id']}") if fetched.status_code != 200: raise SystemExit(f"fetch refused: {fetched.json()['code']}") record = fetched.json() print(record["type"], record["record"]["product_name"], "AI policy version", record["ai_policy"]["version"]) assert record["record_id"] == first["record_id"] ``` Run it with `MASTERDB_KEY_FILE=retrieval-key.pem` and `MASTERDB_SANDBOX_KEY_GRANT` set to the grant from step 2. The first request that carries the grant registers your key in the sandbox; without it that request is refused `401` `key_unknown`. The TypeScript SDK sends the grant for you (`sandboxKeyGrant`), and so does the Python signing helper (`MasterDBAuth(key, sandbox_key_grant=…)`). Once the key is registered in the sandbox the grant is no longer needed. ## What came back A search answers at most 50 **rows** and a **receipt**. A row is a short, signed projection of one record: its id, its business, its origin hash, the fields of its collection (for a product, a price for each country it is published in, such as `price_US` and `price_currency_US`), and `ai_policy_bits` — the bits saying what that business permits and the contexts it blocks. The receipt is MasterDB's signed statement of what it served you. A fetch answers the **record** exactly as the business sealed it, the **seal**, MasterDB's signed **sidecar**, the business's sealed **AI policy** in force, **provenance** (where to find its certificate, which projection made its rows, its log leaf) and a receipt. ## Next - [Search](/ai-companies/search/): queries, filters, sorts, the five collections. - [Verify](/ai-companies/verify/): check a record, a row and a receipt without trusting the response. - [AI policy](/ai-companies/ai-policy/): what each business permits and the contexts it blocks, and how to honour it. - [Rate limits and errors](/ai-companies/rate-limits-and-errors/). --- # Keys > Retrieval keys — how an AI company makes, registers, rotates and revokes the keys its systems sign every request with. Source: https://docs.masterdb.ai/ai-companies/keys/ An AI company's systems authenticate with **retrieval keys**, never a bearer token or an API key string. You make the key pair; you register the public half; every request your systems send is signed with the private half ([Signing requests](/ai-companies/signing-requests/)). There is no secret for MasterDB to hold, leak or reset. ## Making a key Ed25519 is the default; P-256 (ES256) is also accepted. Make the key where it will be used — a secrets manager, an HSM, or the host itself: ```sh openssl genpkey -algorithm ed25519 -out retrieval-key.pem openssl pkey -in retrieval-key.pem -pubout -out retrieval-key.pub.pem ``` The [CLI](/ai-companies/cli/)'s `masterdb keygen` also makes keys, marked as **test** keys; use them in the sandbox only. ## Registering it In the AI Portal, a person with the `integration_manager` role, signed in with a passkey, registers the public half as a JWK, with a label (`"us-east inference fleet"`, say). The answer names the key's id — the [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) thumbprint of the public JWK — which your requests carry as `keyid`. - A key belongs to one legal entity of your company, and through it to your **AI group**. Every request resolves to `(ai_company_uuid, ai_group_id)` from the key alone; any identifier in a body, query or header is ignored. - Every region holds the new key within seconds. A key a region does not hold is refused (`key_unknown`); it is never looked up on the request path. - A **production** key is issued only to a verified AI company whose owner has accepted the **AI-company Terms** version in force. When a new version comes into force, the keys already issued keep working, but the next key — registered or rotated in — waits until the new version is accepted: until then the answer is `agreement_required` (403). The certificate is issued the same way: once the Terms are accepted. - A **sandbox** key is taken in the same AI Portal, as a separate kind of key, and needs neither verification nor the Terms ([The sandbox](/sandbox/#taking-a-sandbox-key-in-the-ai-portal)). The answer carries a `sandbox_key_grant` (`mdb_sbxk1.…`), MasterDB's signed statement of the key and your company, which holds no secret. **Send it as the `MDB-Sandbox-Key` header on your sandbox requests**: the first request that carries it registers the key in the sandbox's own key register, and without it that first request is refused `key_unknown` (401). The TypeScript SDK sends it for you (`createRetrievalClient({ …, sandboxKeyGrant })`), and so does the Python signing helper (`MasterDBAuth(key, sandbox_key_grant=grant)`) and the CLI (`masterdb verify --url … --sandbox-key-grant GRANT`). A key revoked in the portal stops working in the sandbox within about a minute, grant or not. Production never holds a sandbox key and ignores the header. A company that already publishes a key directory at `/.well-known/http-message-signatures-directory` can ask for its keys to be imported from there instead of pasting them; the import is an audited job that pins each key's thumbprint and re-checks the directory daily. Nothing is ever fetched from your directory while a request is being answered. ## Rotating Rotation is overlap: register the new key, move your systems to it, then revoke the old one. Both are valid in between, and your usage shows traffic per key, so you can see when the old one has gone quiet. The portal's rotate action does the register-then-revoke in one step. ## Revoking Revocation takes effect in every region within seconds and fails closed: a revoked key is removed from the region's memory, and a key that is not in memory is refused. If a key may be compromised, revoke it at once; a stolen key's requests are billed to its owner until then, and the receipts and your own reconciliation show them. ## What a key is not - Not an account. Anyone holding the private half can sign as your company, so keep it where your inference runs and nowhere else. - Not shared with MasterDB. MasterDB stores the public half only. - Not reusable across environments. A sandbox key never works in production. --- # Signing requests > Exactly how every request to MasterDB is signed with RFC 9421 HTTP Message Signatures and an RFC 9530 Content-Digest, what the server checks, and a complete implementation in Python. Source: https://docs.masterdb.ai/ai-companies/signing-requests/ Every request an AI company's system makes is signed with [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) HTTP Message Signatures by one of its [retrieval keys](/ai-companies/keys/). The signature replaces a bearer token entirely: there is no credential in the request to steal, and a captured request cannot be replayed. A business's system signs its pushes the same way, with a different tag. The TypeScript SDK signs for you (`createRetrievalClient`, `createBusinessClient`). In Python, use the file at the end of this page. Nobody should need to write this by hand; this page is for those who must. ## What is signed | | | |---|---| | Covered components | `"@method" "@authority" "@path"`, then `"@query"` when the request has a query string, then `"content-digest"` when it has a body | | Parameters | `created`, `expires`, `nonce`, `keyid`, `tag`, in that order | | `created`, `expires` | Unix seconds; `expires` at most 300 seconds after `created` | | `nonce` | fresh for every request: 32 random bytes, base64url | | `keyid` | the key's id, its RFC 7638 thumbprint | | `tag` | `mdb-retrieval` for the retrieval API; `mdb-push` for publishing; `mdb-business-read` for a business reading its own data | | Algorithm | Ed25519 (or ES256 as raw `r ‖ s`); it comes from the registered key, never from the request | | Label | `sig1` | `Content-Digest` is `sha-256=::` ([RFC 9530](https://www.rfc-editor.org/rfc/rfc9530)), over the exact bytes you send. `@authority` is the host you address, lower case, without a default port — which is what keeps a sandbox request from being accepted by production. The signature base for a search looks like this (one line per component, then the parameters; no trailing newline): ```text "@method": POST "@authority": sandbox.api.masterdb.ai "@path": /v1/search "content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: "@signature-params": ("@method" "@authority" "@path" "content-digest");created=1790000000;expires=1790000300;nonce="kPq0e3Yc6sJ0mX8tq2f5b1c4d7e9a0b3c6d9e2f5a8b1c4d";keyid="L1BSyf0VsZoYxYTQE2NWgZhhPww06EQJ73k4cJoVnsI";tag="mdb-retrieval" ``` and the request carries: ```http Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: Signature-Input: sig1=("@method" "@authority" "@path" "content-digest");created=1790000000;expires=1790000300;nonce="kPq0…";keyid="L1BS…";tag="mdb-retrieval" Signature: sig1=:: ``` ## What MasterDB checks In this order, in the region that received the request, with nothing fetched from anywhere: 1. `Signature-Input` and `Signature` are present. Otherwise `signature_missing`. 2. Exactly one signature carries the tag this API accepts; its parameters are the ones above and nothing else; the nonce is 16 to 128 printable characters; the covered components include every one required; `@authority` is this deployment's host. Otherwise `signature_invalid`. 3. `created` is no more than 30 seconds in the future, `expires` has not passed, and `expires` is 1 to 300 seconds after `created`. Otherwise `signature_expired`. Keep your clock synchronised. 4. The key is one the region holds, not revoked. Otherwise `key_unknown`. 5. `Content-Digest` is recomputed over the body received. Otherwise `digest_mismatch`. 6. The signature verifies over the signature base. Otherwise `signature_invalid`. 7. The nonce has not been seen with this key. Otherwise `nonce_reused`. A nonce is claimed only once the signature has verified, and is held until the signature expires. A request refused afterwards — for its body, its rate or its allowance — has used its nonce: sign again for a retry. Each signed request is **billed once**: billing counts each `(keyid, nonce)` once. ## Your signature is your receipt's other half Every receipt carries `request_hash`: the SHA-256 of your request's signature base. Your signature is your statement that you asked; the receipt is MasterDB's that it answered; each binds the other. Keep your signature bases if you want to reconcile to the request. ## Python: the signing helper The Python samples on this site import this one file. It is `httpx.Auth` for RFC 9421, plus the two seals a business's system makes ([Pushes](/businesses/pushes/)). It runs against the sandbox with every other sample. ```python title="samples/python/masterdb_signing.py" """RFC 9421 request signing and Path A sealing for MasterDB, in Python. The Python samples sign with this one file. It does exactly what the TypeScript SDK does: * ``MasterDBAuth``, an ``httpx.Auth`` that signs every request with the components MasterDB requires — ``"@method" "@authority" "@path"``, ``"@query"`` when there is a query, ``"content-digest"`` (RFC 9530, sha-256) when there is a body — and the parameters ``created``, ``expires`` (five minutes), ``nonce``, ``keyid`` (the key's RFC 7638 thumbprint) and ``tag``. For the sandbox, ``sandbox_key_grant`` sends the key's grant as the ``MDB-Sandbox-Key`` header, as the TypeScript SDK's ``sandboxKeyGrant`` does. * ``seal_batch`` and ``seal_action``, the DSSE seals of a Path A push and of a sealed withdrawal or deletion. Copy it into your project. Needs ``httpx``, ``cryptography`` and ``masterdb-verifier`` (for PAE, JCS and the Merkle tree). """ from __future__ import annotations import base64 import hashlib import json import secrets import time from collections.abc import Callable, Generator, Sequence from datetime import datetime, timezone from typing import Any import httpx from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from masterdb_verifier import jcs_bytes, leaf_hash, merkle_root, pae SANDBOX_API = "https://sandbox.api.masterdb.ai" PRODUCTION_API = "https://api.masterdb.ai" RETRIEVAL = "mdb-retrieval" PUSH = "mdb-push" BUSINESS_READ = "mdb-business-read" SANDBOX_KEY_HEADER = "MDB-Sandbox-Key" SANDBOX_KEY_GRANT_PREFIX = "mdb_sbxk1" def _b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") def load_key(path: str) -> Ed25519PrivateKey: """Your private key from a PKCS #8 PEM file. It never leaves your process.""" with open(path, "rb") as f: key = serialization.load_pem_private_key(f.read(), password=None) if not isinstance(key, Ed25519PrivateKey): raise TypeError("expected an Ed25519 private key") return key def key_id(key: Ed25519PrivateKey) -> str: """The key's id: the RFC 7638 thumbprint of its public JWK.""" x = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) jwk = json.dumps({"crv": "Ed25519", "kty": "OKP", "x": _b64url(x)}, separators=(",", ":"), sort_keys=True) return _b64url(hashlib.sha256(jwk.encode("ascii")).digest()) def business_tag(method: str) -> str: """A business's system: reading its own data is ``mdb-business-read``; publishing is ``mdb-push``.""" return BUSINESS_READ if method == "GET" else PUSH class MasterDBAuth(httpx.Auth): """Signs each request with RFC 9421 (and RFC 9530 ``Content-Digest``). ``sandbox_key_grant`` is for the sandbox only: the ``sandbox_key_grant`` the portal answered when the key was taken. It is sent as ``MDB-Sandbox-Key`` on every request, so the sandbox admits the key on its first request instead of refusing it ``key_unknown``. It holds no secret and is not part of the signature. Production ignores the header. Unset, the header is not sent. """ requires_request_body = True def __init__( self, key: Ed25519PrivateKey, tag: str | Callable[[str], str] = RETRIEVAL, *, sandbox_key_grant: str | None = None ) -> None: if sandbox_key_grant is not None and not sandbox_key_grant.startswith(SANDBOX_KEY_GRANT_PREFIX + "."): raise ValueError( f"sandbox_key_grant is the sandbox_key_grant the portal answered ({SANDBOX_KEY_GRANT_PREFIX}.…), not a key or a bearer token" ) self.sandbox_key_grant = sandbox_key_grant self.key = key self.key_id = key_id(key) self.tag = tag def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: tag = self.tag(request.method) if callable(self.tag) else self.tag if self.sandbox_key_grant is not None: request.headers[SANDBOX_KEY_HEADER] = self.sandbox_key_grant raw = request.url.raw_path.decode("ascii") path, _, query = raw.partition("?") authority = request.url.netloc.decode("ascii").lower() default_port = ":443" if request.url.scheme == "https" else ":80" if authority.endswith(default_port): authority = authority[: -len(default_port)] components = [("@method", request.method), ("@authority", authority), ("@path", path or "/")] if query: components.append(("@query", "?" + query)) body = request.content if body: digest = "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode("ascii") + ":" request.headers["Content-Digest"] = digest components.append(("content-digest", digest)) created = int(time.time()) params = ( "(" + " ".join(f'"{name}"' for name, _ in components) + ")" + f";created={created};expires={created + 300}" + f';nonce="{_b64url(secrets.token_bytes(32))}";keyid="{self.key_id}";tag="{tag}"' ) base = "\n".join(f'"{name}": {value}' for name, value in components) + f'\n"@signature-params": {params}' signature = self.key.sign(base.encode("utf-8")) request.headers["Signature-Input"] = f"sig1={params}" request.headers["Signature"] = "sig1=:" + base64.b64encode(signature).decode("ascii") + ":" yield request def _envelope(payload_type: str, payload: dict[str, Any], key: Ed25519PrivateKey) -> dict[str, Any]: body = jcs_bytes(payload) sig = key.sign(pae(payload_type, body)) return { "payloadType": payload_type, "payload": base64.b64encode(body).decode("ascii"), "signatures": [{"keyid": key_id(key), "sig": base64.b64encode(sig).decode("ascii")}], } def _now() -> str: return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") def seal_batch( records: Sequence[bytes], key: Ed25519PrivateKey, *, cert_id: str, ai_policy_version: int, seq: int, record_type: str = "products" ) -> dict[str, Any]: """The body of ``POST /v1/publish/{type}``: one seal over the RFC 6962 Merkle root of the records' exact bytes.""" if not 1 <= len(records) <= 1000: raise ValueError("a batch holds 1 to 1,000 records") root = merkle_root([leaf_hash(r) for r in records]) payload = { "v": 2, "key_id": key_id(key), "cert_id": cert_id, "root": "sha256:" + root.hex(), "tree_size": len(records), "seq": seq, "sealed_at": _now(), "record_type": record_type, "ai_policy_version": ai_policy_version, } return { "batch_seal": _envelope("application/vnd.masterdb.batch-seal.v2+json", payload, key), "records": [base64.b64encode(r).decode("ascii") for r in records], } def seal_action(record_id: str, action: str, key: Ed25519PrivateKey, *, cert_id: str, ai_policy_version: int, record_type: str = "products") -> dict[str, Any]: """The body of ``POST /v1/publish/{type}/withdraw`` or ``/delete``: the sealed document ``{record_id, action, at}``.""" at = _now() document = json.dumps({"record_id": record_id, "action": action, "at": at}, separators=(",", ":")).encode("utf-8") payload = { "v": 2, "key_id": key_id(key), "cert_id": cert_id, "hash": "sha256:" + hashlib.sha256(document).hexdigest(), "sealed_at": at, "record_type": record_type, "ai_policy_version": ai_policy_version, } return {"document_base64": base64.b64encode(document).decode("ascii"), "seal": _envelope("application/vnd.masterdb.seal.v2+json", payload, key)} ``` --- # Search > Find candidates in one collection with your own text query, structured filter and sort — exactly one country, at most 50 rows, no pagination, and a signed receipt. Source: https://docs.masterdb.ai/ai-companies/search/ `POST /v1/search` finds candidates in one collection. You write the query, the filter and the order; MasterDB validates them against the collection's allow-list, composes the index query itself, and answers at most 50 signed rows with a receipt. MasterDB never supplies an order and never rewrites a request: a request it cannot answer exactly is refused with a reason. ```typescript title="samples/typescript/search.ts" // Search: your query, your filter, your sort — in one collection and one country. // // MASTERDB_KEY_FILE your retrieval key's private half (PKCS #8 PEM); its public half is the sandbox key you took in the AI Portal // MASTERDB_SANDBOX_KEY_GRANT optional; that key's sandbox_key_grant (mdb_sbxk1.…), needed until the sandbox has seen the key once // MASTERDB_API_URL optional; the sandbox by default import assert from 'node:assert/strict'; import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { SANDBOX, createEd25519Signer, createRetrievalClient } from '@masterdb/client'; const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_KEY_FILE as string))); const sandboxKeyGrant = process.env.MASTERDB_SANDBOX_KEY_GRANT; const client = createRetrievalClient({ baseUrl: process.env.MASTERDB_API_URL ?? SANDBOX.retrieval, signer, ...(sandboxKeyGrant ? { sandboxKeyGrant } : {}) }); // Relevance order is a sort you ask for (`_text_match:desc`); a price filter is in the searched country's price. const products = await client.POST('/v1/products/search', { body: { q: 'collection', query_by: ['product_name', 'short_description'], filter: { all: [{ country: 'GB' }, { price_amount: { lte: '400.00' } }, { on_sale: false }] }, sort_by: '_text_match:desc', limit: 10, }, }); if (products.error) throw new Error(`${products.error.code}: ${products.error.detail ?? ''}`); for (const row of products.data.rows) console.log(row.record_id, row['product_name'], row['price_GB'], row['price_currency_GB']); assert.ok(products.data.rows.length <= 10); assert.ok(products.data.rows.every((row) => (row['countries'] as string[]).includes('GB'))); assert.ok(products.data.rows.every((row) => (row['price_GB'] as number) <= 400)); // The same API finds every type. Business & Brand files in Ireland, newest first: const files = await client.POST('/v1/search', { body: { collection: 'business_files', filter: { all: [{ country: 'IE' }] }, sort_by: 'published_at:desc', limit: 20 }, }); if (files.error) throw new Error(files.error.code); for (const row of files.data.rows) console.log(row.record_id, row['business_name']); assert.ok(files.data.rows.length > 0); // At most 50 rows and no second page. If the answer is not in them, narrow the criteria and search again. const narrower = await client.POST('/v1/search', { body: { collection: 'products', filter: { all: [{ country: 'US' }, { vertical: 'fashion' }] }, sort_by: 'published_at:desc', limit: 50 }, }); if (narrower.error) throw new Error(narrower.error.code); assert.ok(narrower.data.rows.every((row) => row['vertical'] === 'fashion')); console.log(`${narrower.data.rows.length} fashion products in the US`); ``` ```python title="samples/python/search.py" """Search: your query, your filter, your sort — in one collection and one country. MASTERDB_KEY_FILE your retrieval key's private half (PKCS #8 PEM); its public half is the sandbox key you took in the AI Portal MASTERDB_SANDBOX_KEY_GRANT optional; that key's sandbox_key_grant (mdb_sbxk1.…), needed until the sandbox has seen the key once MASTERDB_API_URL optional; the sandbox by default """ import os import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, load_key grant = os.environ.get("MASTERDB_SANDBOX_KEY_GRANT") client = httpx.Client( base_url=os.environ.get("MASTERDB_API_URL", SANDBOX_API), auth=MasterDBAuth(load_key(os.environ["MASTERDB_KEY_FILE"]), sandbox_key_grant=grant or None), ) def search(path: str, body: dict) -> dict: res = client.post(path, json=body) if res.status_code != 200: problem = res.json() raise SystemExit(f"{problem['code']}: {problem.get('detail', '')}") return res.json() # Relevance order is a sort you ask for (`_text_match:desc`); a price filter is in the searched country's price. products = search( "/v1/products/search", { "q": "collection", "query_by": ["product_name", "short_description"], "filter": {"all": [{"country": "GB"}, {"price_amount": {"lte": "400.00"}}, {"on_sale": False}]}, "sort_by": "_text_match:desc", "limit": 10, }, ) for row in products["rows"]: print(row["record_id"], row["product_name"], row["price_GB"], row["price_currency_GB"]) assert len(products["rows"]) <= 10 assert all("GB" in row["countries"] and row["price_GB"] <= 400 for row in products["rows"]) # The same API finds every type. Business & Brand files in Ireland, newest first: files = search("/v1/search", {"collection": "business_files", "filter": {"all": [{"country": "IE"}]}, "sort_by": "published_at:desc", "limit": 20}) for row in files["rows"]: print(row["record_id"], row["business_name"]) assert files["rows"] # At most 50 rows and no second page. If the answer is not in them, narrow the criteria and search again. narrower = search( "/v1/search", {"collection": "products", "filter": {"all": [{"country": "US"}, {"vertical": "fashion"}]}, "sort_by": "published_at:desc", "limit": 50}, ) assert all(row["vertical"] == "fashion" for row in narrower["rows"]) print(len(narrower["rows"]), "fashion products in the US") ``` Run it with `MASTERDB_KEY_FILE` (your retrieval key's private half) and, against the sandbox, `MASTERDB_SANDBOX_KEY_GRANT` set to the grant the AI Portal answered with the key. Both samples send the grant as the `MDB-Sandbox-Key` header on every request — the TypeScript SDK's `sandboxKeyGrant`, the Python signing helper's `MasterDBAuth(key, sandbox_key_grant=…)` — so the sandbox admits a key it has not seen yet instead of refusing it `401` `key_unknown`. Once the key is registered the grant is no longer needed. `MASTERDB_API_URL` is optional: the sandbox by default. ## The request ```json { "collection": "products", "q": "trail running shoes", "query_by": ["product_name", "tags"], "filter": { "all": [ { "country": "US" }, { "price_amount": { "lte": "150.00" } }, { "availability": "available" } ] }, "sort_by": "price_amount:asc", "limit": 25 } ``` | Member | | |---|---| | `collection` | `products`, `business_files`, `events`, `jobs` or `updates`. Optional on a typed address — `POST /v1/products/search`, `/v1/business-files/search`, `/v1/events/search`, `/v1/jobs/search`, `/v1/updates/search` — and must match it if given. | | `q` | The text query, up to 512 characters. Default `*`: everything the filter admits. | | `query_by` | Which text fields `q` is matched against. Default: all of the collection's text fields. | | `filter` | `{"all": [clause, …]}`: every clause must hold. **Exactly one clause names the country** (ISO 3166-1 alpha-2); none or two is `filter_required`. At most 20 clauses. | | `sort_by` | **Mandatory.** `field:asc` or `field:desc`, up to three, comma-separated. For relevance order write `_text_match:desc`. Without it: `sort_required`. | | `limit` | 1 to 50; default 50. | A clause is an object with one member, the field name, and either a value (`{"on_sale": false}`, shorthand for `eq`) or operators (`{"price_amount": {"gte": "20.00", "lt": "80.00"}}`). Strings take `eq`, `ne`, `in` and `nin` where the field allows them; money, numbers and timestamps take `eq`, `lt`, `lte`, `gt` and `gte`; locations take `near` with `lat`, `lng` and `radius_km`. Money in a filter is a decimal string. **[Fields, filters and sort keys](/reference/search-fields/)** lists every field of every collection with its operators, generated from the allow-lists the service enforces. **One country, and its prices.** The country clause selects the rows published for that country. On products, `price_amount` and `price_currency` in a filter or sort mean *that country's* price. ## The answer ```json { "retrieval_id": "r_…", "collection": "products", "country": "US", "rows": [ { "record_id": "mdb_…", "business_uuid": "…", "type": "products", "product_name": "…", "price_US": 10.34, "price_currency_US": "USD", "adl_origin": "sha256:…", "adl_proj": "sha256:…", "adl_row_sig": "…", "adl_key_id": "…", "ai_policy_bits": { "ai_policy_version": 1, "ai_policy_schema": 2, "use": 79, "action": 35, "blocked": 0 } } ], "receipt": { "payloadType": "application/vnd.masterdb.receipt.v3+json", "payload": "…", "signatures": [ … ] } } ``` - Each **row** is exactly what the projection wrote and signed, plus members set as it is served: `ai_policy_bits` (the business's [AI policy](/ai-companies/ai-policy/) in force — what it permits and the contexts it blocks, bc1–bc10) and `sponsored` (present, and `true`, only on a paid placement). A product row carries the price for every country it is published in (`price_GB`, `price_US`, …) as signed; rows carry numbers, records carry decimal strings. - `partial: true` appears when the index stopped at its time budget and answered with what it had. Narrow the query. - The **receipt** lists every row served with its origin hash and signature ([Receipts](/ai-companies/receipts/)). - `published_at` on a row is Unix seconds; on a record it is an RFC 3339 timestamp. - **Freshness.** When the business declares how fresh records of this type are expected to be, the row also carries `freshness_expectation` — `{"cadence": "daily"}`, or `{"cadence": "interval", "interval_hours": 12}`; cadences `hourly`, `daily`, `weekly`, `monthly` (31 days), `quarterly` (92), `yearly` (366), `interval`. Like `ai_policy_bits` it is set as the row is served and is outside the row signature; the business's sealed Business & Brand file (a fetch of its `bf_…` record, member `freshness_expectation`) is what proves it. **A record is stale when it is older than its expectation** — now minus `published_at` is more than the window — and never stale without one: `isStale` / `is_stale` in the [verifier libraries](/ai-companies/verifier-libraries/). Say "updated 2 minutes ago", or flag what is past its window, rather than presenting it as current. Rows from a business that blocked your group are absent, and nothing says so ([Blocking](/ai-companies/blocking/)). ## At most 50, and no second page There is no pagination, no cursor and no "more". If the answer is not in the 50 rows, the search was too wide: add a filter, narrow the price range, pick a category, and search again. This is deliberate: it keeps a search a search, and makes walking the whole corpus a matter of millions of separately billed requests. There is also no list endpoint and no batch fetch. ## Billing A search that returns at least one row is one query. A search that returns no rows is free and unrestricted beyond the ordinary rate limit — use it to explore. Refusals are never billed. See [Usage and billing](/ai-companies/usage-and-billing/). ## When the answer is not in a row A row is short by design. For everything the business sealed — descriptions, all prices, opening hours, how it wants to be represented, its sealed AI policy — [fetch the record](/ai-companies/fetch/). --- # Fetch a record > Get one record of any type exactly as the business sealed it, with its seal, MasterDB's sidecar, the business's sealed AI policy, provenance and a receipt. Source: https://docs.masterdb.ai/ai-companies/fetch/ `GET /v1/records/{record_id}` answers one record of any of the five types — the bytes exactly as the business sealed them — and everything needed to check it. One id per call: there is no batch fetch, no list and no export. ```typescript title="samples/typescript/fetch-and-verify.ts" // Fetch one record, then check it without trusting the response: the receipt offline against // MasterDB's key set, the seal through the public verify endpoint, the business's certificate offline. // // MASTERDB_ANCHORS_FILE the trust anchors (JSON array of root JWKs); the pinned sandbox anchors by default import assert from 'node:assert/strict'; import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { SANDBOX, createEd25519Signer, createPublicClient, createRetrievalClient } from '@masterdb/client'; import { KeySet, SANDBOX_ANCHORS, recordBytes, sha256Tagged, verifyCertificate, verifyReceipt } from '@masterdb/verifier'; const baseUrl = process.env.MASTERDB_API_URL ?? SANDBOX.retrieval; const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_KEY_FILE as string))); const client = createRetrievalClient({ baseUrl, signer }); const anchors = process.env.MASTERDB_ANCHORS_FILE ? JSON.parse(readFileSync(process.env.MASTERDB_ANCHORS_FILE, 'utf8')) : SANDBOX_ANCHORS; // A product of Garnet Mill, a fictional Irish business in the sandbox corpus. const recordId = 'mdb_xmomas3i3kzkdwyqpfoxou5rea'; // Read the body as text: the record's bytes are a span of it, and a seal covers exactly those bytes. const res = await client.GET('/v1/records/{record_id}', { params: { path: { record_id: recordId } }, parseAs: 'text' }); if (res.error) throw new Error(`fetch refused: ${JSON.stringify(res.error)}`); const text = res.data as unknown as string; const bytes = recordBytes(text); const response = JSON.parse(text); // 1. The receipt: MasterDB's signed statement of what it served you. Verified offline, from the anchors. const keys = await KeySet.fetch(baseUrl, { anchors, sandbox: true }); const receipt = verifyReceipt(response.receipt, keys); assert.equal(receipt.rows[0]?.id, recordId); assert.equal(receipt.rows[0]?.adl_origin, sha256Tagged(bytes), 'the receipt names the hash of the bytes you received'); console.log('receipt', receipt.retrieval_id, 'served', receipt.served_at, 'in', receipt.region, 'sandbox:', receipt.sandbox); // 2. The seal: who published these exact bytes, and when. The public verify endpoint needs no account, // and asks for the record itself, never a bare id. const publicApi = createPublicClient({ baseUrl }); const check = await publicApi.POST('/v1/verify', { body: { record_base64: Buffer.from(bytes).toString('base64'), seal: response.seal, sidecar: response.sidecar }, }); if (check.error) throw new Error(check.error.code); assert.equal(check.data.seal.valid, true); console.log('seal valid, sealed at', check.data.seal.sealed_at, 'checks', JSON.stringify(check.data.seal.checks)); // 3. The certificate the seal names: the business's verified identity, signed by MasterDB's issuance key. const cert = await publicApi.GET('/v1/certificates/{uuid}', { params: { path: { uuid: response.business_uuid } } }); if (cert.error) throw new Error(cert.error.code); const verified = verifyCertificate(cert.data.certificate, keys); assert.equal(verified.cert_id, check.data.seal.cert_id); assert.equal(verified.status, 'active'); console.log('certificate', verified.cert_id, verified.status, String(verified.payload['legal_name'])); ``` ```python title="samples/python/fetch_and_verify.py" """Fetch one record, then check it without trusting the response: the receipt offline against MasterDB's key set, the seal through the public verify endpoint, the business's certificate offline. MASTERDB_ANCHORS_FILE the trust anchors (JSON array of root JWKs); the pinned sandbox anchors by default """ import base64 import json import os import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, load_key from masterdb_verifier import SANDBOX_ANCHORS, KeySet, parse_strict, record_bytes, sha256_tagged, verify_certificate, verify_receipt base_url = os.environ.get("MASTERDB_API_URL", SANDBOX_API) client = httpx.Client(base_url=base_url, auth=MasterDBAuth(load_key(os.environ["MASTERDB_KEY_FILE"]))) public = httpx.Client(base_url=base_url) # the verification API needs no account and no signature anchors = json.load(open(os.environ["MASTERDB_ANCHORS_FILE"])) if "MASTERDB_ANCHORS_FILE" in os.environ else SANDBOX_ANCHORS # A product of Garnet Mill, a fictional Irish business in the sandbox corpus. record_id = "mdb_xmomas3i3kzkdwyqpfoxou5rea" res = client.get(f"/v1/records/{record_id}") res.raise_for_status() # The record's bytes are a span of the body, and a seal covers exactly those bytes: take them, never re-serialise. data = record_bytes(res.content) response = parse_strict(res.content) # 1. The receipt: MasterDB's signed statement of what it served you. Verified offline, from the anchors. keys = KeySet.fetch(base_url, anchors=anchors, sandbox=True) receipt = verify_receipt(response["receipt"], keys) assert receipt.rows[0]["id"] == record_id assert receipt.rows[0]["adl_origin"] == sha256_tagged(data), "the receipt names the hash of the bytes you received" print("receipt", receipt.retrieval_id, "served", receipt.served_at, "in", receipt.region, "sandbox:", receipt.sandbox) # 2. The seal: who published these exact bytes, and when. The public verify endpoint asks for the # record itself, never a bare id. check = public.post( "/v1/verify", json={"record_base64": base64.b64encode(data).decode("ascii"), "seal": response["seal"], "sidecar": response["sidecar"]}, ) check.raise_for_status() seal = check.json()["seal"] assert seal["valid"] is True print("seal valid, sealed at", seal["sealed_at"], "checks", seal["checks"]) # 3. The certificate the seal names: the business's verified identity, signed by MasterDB's issuance key. cert = public.get(f"/v1/certificates/{response['business_uuid']}") cert.raise_for_status() verified = verify_certificate(parse_strict(cert.content)["certificate"], keys) assert verified.cert_id == seal["cert_id"] and verified.status == "active" print("certificate", verified.cert_id, verified.status, verified.payload["legal_name"]) ``` ## The answer ```json { "record_id": "mdb_xmomas3i3kzkdwyqpfoxou5rea", "business_uuid": "6c1658dd-fa53-4f12-8a18-6eee69f5ca99", "type": "products", "published_at": "2026-10-01T00:00:00.000Z", "record": { "schema": "masterdb/products/1", "business_product_id": "GARNET-00001", "…": "…" }, "seal": { "…": "the business's DSSE seal" }, "sidecar": { "…": "MasterDB's signed sidecar" }, "ai_policy": { "version": 1, "record": { "schema": "masterdb/ai_policy/1", "ai_policy_schema": 2, "answer": true, "…": "…", "blocked": { "adult_sexual": false, "alcohol": true, "…": "…" } }, "seal": { "…": "…" } }, "provenance": { "certificate_url": "https://…/v1/certificates/6c1658dd-…", "projection_version": "products/…", "log_leaf": "sha256:…" }, "receipt": { "payloadType": "application/vnd.masterdb.receipt.v3+json", "…": "…" } } ``` | Member | What it is | |---|---| | `record` | The sealed bytes, **verbatim**. MasterDB writes the stored bytes into the response without re-serialising them. To check the seal, hash **the span of the response body** that is this member's value — `recordBytes(text)` in TypeScript, `record_bytes(body)` in Python — never a re-serialisation of the parsed object. | | `seal` | The business's seal over those bytes: a DSSE envelope (Path B), or the batch envelope with this record's leaf index and inclusion proof (Path A). | | `sidecar` | MasterDB's signed statement of what it checked at publish: the resolved scope, the certificate and AI policy version in force, where the record came from, and, for records with an address, the geocoded coordinate — never written into the business's bytes. | | `ai_policy` | The business's sealed AI policy record in force now: `{version, record, seal}`, or `{version: 0, record: null, seal: null}` if it has never sealed one — what proves a row's `ai_policy_bits`. See [AI policy](/ai-companies/ai-policy/). | | `represent` | Business & Brand files only: the texts the business wrote about how it wants to be represented. | | `provenance` | Where to find the business's certificate, which projection version made the record's rows, and the seal's leaf in the transparency log. | | `receipt` | MasterDB's signed statement that it served you this record, naming the hash of its bytes and the AI policy version applied. | ## Not found, blocked, withdrawn: one answer A record from a business that blocked your group, a withdrawn record, a deleted record, a record that never existed, a malformed id, and an id of a type that is not fetchable all answer **the same `404`**, with the same problem body (its `instance` echoes the path you asked for), and **no sooner than 20 ms** after the request arrived — so none can be told from another. See [Blocking is invisible](/ai-companies/blocking/). ## Billing and use A fetch that returns a record is one query; a `404` is not billed. What you may do with the record is governed by the AI-company Terms (the version in force on the receipt) and by the business's sealed AI policy: among the Terms' conditions, a record is used once, in one conversation, and is not cached or used for training. --- # Verify what you received > Check rows, records, receipts and certificates without trusting MasterDB — offline with the verifier libraries, or through the public verify endpoint that needs no account. Source: https://docs.masterdb.ai/ai-companies/verify/ Everything MasterDB serves can be checked without trusting MasterDB, and everything needed to check it is public: the signed key set, certificates, projection specifications, the transparency log. Three questions, three answers: | Question | Check | Where | |---|---|---| | Is this the real business? | Its **certificate**, signed by MasterDB's issuance key, chained to the pinned trust anchors | offline: `verifyCertificate` / `verify_certificate` | | Is this row what MasterDB projected? Is this what MasterDB served me? | The **row signature** and the **receipt** | offline: `verifyServedRow`, `verifyReceipt` / `verify_served_row`, `verify_receipt` | | Is this record what the business published? | The **seal** over the exact bytes, the certificate and AI policy version in force when it was sealed, the scope, the log inclusion | `POST /v1/verify`, public, no account | ## Rows and receipts, offline ```typescript title="samples/typescript/verify-rows.ts" // Verify a search offline: every row's projection signature and the receipt that lists them. // Answering from rows alone is allowed; this is how you know the rows are what MasterDB projected. import assert from 'node:assert/strict'; import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { SANDBOX, createEd25519Signer, createRetrievalClient } from '@masterdb/client'; import { KeySet, SANDBOX_ANCHORS, verifyReceipt, verifyServedRow } from '@masterdb/verifier'; const baseUrl = process.env.MASTERDB_API_URL ?? SANDBOX.retrieval; const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_KEY_FILE as string))); const client = createRetrievalClient({ baseUrl, signer }); const anchors = process.env.MASTERDB_ANCHORS_FILE ? JSON.parse(readFileSync(process.env.MASTERDB_ANCHORS_FILE, 'utf8')) : SANDBOX_ANCHORS; const keys = await KeySet.fetch(baseUrl, { anchors, sandbox: true }); const { data, error } = await client.POST('/v1/search', { body: { collection: 'events', filter: { all: [{ country: 'US' }] }, sort_by: 'published_at:desc', limit: 20 }, }); if (error) throw new Error(error.code); for (const row of data.rows) { // Refuses (VerificationError with a reason) if a byte of the signed fields differs from what was projected. const v = verifyServedRow(row, keys); console.log('row', v.record_id, 'projection', v.projection.version, 'origin', v.adl_origin.slice(0, 20)); } // The receipt lists each row's id, origin hash and signature: passing the rows checks they match it. const receipt = verifyReceipt(data.receipt, keys, { rows: data.rows }); assert.equal(receipt.rows.length, data.rows.length); assert.equal(receipt.country, 'US'); console.log(`${receipt.rows.length} rows verified; receipt ${receipt.retrieval_id}`); ``` ```python title="samples/python/verify_rows.py" """Verify a search offline: every row's projection signature and the receipt that lists them. Answering from rows alone is allowed; this is how you know the rows are what MasterDB projected.""" import json import os import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, load_key from masterdb_verifier import SANDBOX_ANCHORS, KeySet, parse_strict, verify_receipt, verify_served_row base_url = os.environ.get("MASTERDB_API_URL", SANDBOX_API) client = httpx.Client(base_url=base_url, auth=MasterDBAuth(load_key(os.environ["MASTERDB_KEY_FILE"]))) anchors = json.load(open(os.environ["MASTERDB_ANCHORS_FILE"])) if "MASTERDB_ANCHORS_FILE" in os.environ else SANDBOX_ANCHORS keys = KeySet.fetch(base_url, anchors=anchors, sandbox=True) res = client.post("/v1/search", json={"collection": "events", "filter": {"all": [{"country": "US"}]}, "sort_by": "published_at:desc", "limit": 20}) res.raise_for_status() # Parse strictly: a row is verified over the values exactly as served, numbers included. data = parse_strict(res.content) for row in data["rows"]: # Raises VerificationError (with a reason) if a byte of the signed fields differs from what was projected. v = verify_served_row(row, keys) print("row", v.record_id, "projection", v.projection["version"], "origin", v.adl_origin[:20]) # The receipt lists each row's id, origin hash and signature: passing the rows checks they match it. receipt = verify_receipt(data["receipt"], keys, rows=data["rows"]) assert len(receipt.rows) == len(data["rows"]) and receipt.country == "US" print(len(receipt.rows), "rows verified; receipt", receipt.retrieval_id) ``` `verifyServedRow` reads the row's projection version from `adl_proj`, removes the members set at serve time (`ai_policy_bits`, `sponsored`), and checks `adl_row_sig` with MasterDB's projection key over the RFC 8785 canonical form. `verifyReceipt` checks the receipt's signature by a receipt key of the region that served you, valid at `served_at`, and — given the rows — that each row matches its receipt entry. An unknown projection version or payload type is refused loudly, never guessed. ## The trust anchors and the key set The verifier trusts nothing but its **anchors**: MasterDB's root keys. It fetches the key set from `GET /.well-known/keys` (the only network call the libraries make) and accepts a key only if its certificate chains to an anchor, following root succession by cross-certification. With a saved key set, everything runs offline. - **Production anchors** are pinned in the verifier libraries, from MasterDB's root key ceremony; **sandbox anchors** are an explicit opt-in (`sandbox: true`). You can also pass anchors explicitly. - A certificate or key marked `sandbox` verifies only under the sandbox anchors, so sandbox data can never pass as production. - Long-lived artefacts — certificates, key certificates, the key set — carry two signatures, the classical one and ML-DSA-65, and the verifier libraries require both. Rows and receipts are Ed25519 only. ## Records: the public verify endpoint `POST /v1/verify` takes the record's bytes (base64), its seal and, optionally, the sidecar and a row, and answers: - `seal`: whether the seal is valid for these bytes, by which key, under which certificate and AI policy version, sealed when, with each check named (`certificate`, `ai_policy`, `scope`, `key_event`); - `log_inclusion`: the seal's leaf in the transparency log, with the checkpoint; - `projection`: given a row, whether the row signature is valid and whether re-running the projection on the record gives that row; - `ai_policy_bits`: given the `ai_policy_bits` a row carried, with the business's sealed AI policy record as the record, whether the bits are the ones its named booleans derive, and which contexts it blocks; - `statement`: all of the above, signed by MasterDB's statement key. **The seal key's own log leaf.** Every key a business adds to its register is a `key_added` leaf in the transparency log. The endpoint proves the seal key's leaf from the log: `checks.key_event` is `ok`, with `key_event` saying where the leaf is, or `missing`, with a line in `warnings`, and the seal stands. Send `"key_events": "require"` to have a missing leaf refuse the seal (`key_event_missing`), as the libraries' `keyEvents: 'require'` does. A key added in the last hour is not sequenced yet. A failed check is a `200` with `valid: false` and a reason. The endpoint needs no account and is never rate-limited so as to block a checker. **It requires the record itself** — a hash or a bare id is not enough — so it cannot be used to learn which ids exist. **Checking a seal from public data alone.** A seal names a key of the business's key register — which of its people's passkeys and which integration keys may seal. Each business's public sealing and integration keys, current and past, are published beside its certificate (`GET /v1/certificates/{uuid}/keys`, signed by MasterDB's statement key), so the libraries check a seal offline from them (`publishedKeys` / `published_keys`); the public endpoint checks it against the register. ## What verification does not prove A seal proves who published the bytes and when, not that they are true. A receipt proves what MasterDB asserted it served; it does not prove the response arrived. See [the security model](/threat-model/). --- # Receipts > MasterDB's signed statement of what it served, to whom, when and which version — on every search and fetch, listable for reconciliation, and folded into the transparency log. Source: https://docs.masterdb.ai/ai-companies/receipts/ Every search and every fetch carries a **receipt**: a DSSE envelope, `payloadType` `application/vnd.masterdb.receipt.v3+json`, signed with the receipt key of the region that answered. Every receipt names your key (`caller_key_id`), so a receipt is bound to the caller it was issued to. Receipts of earlier formats still verify. ```json { "retrieval_id": "r_…", "served_at": "2026-10-01T14:02:11.482Z", "region": "…", "request_hash": "sha256:…", "country": "US", "rows": [ { "id": "mdb_…", "adl_origin": "sha256:…", "row_sig": "…", "ai_policy_version": 1 } ], "key_id": "…", "caller_key_id": "…", "sandbox": true } ``` | Member | | |---|---| | `retrieval_id` | Unique to this delivery. | | `served_at`, `region` | When and where. | | `request_hash` | SHA-256 of your request's RFC 9421 signature base: binds the receipt to the exact request you signed. | | `country` | The one country a search named. | | `rows` | For a search, each row served: its id, origin hash (`adl_origin`), row signature and the business's AI policy version applied (`ai_policy_version`). For a fetch, one entry: the record's id and the SHA-256 of its bytes. | | `key_id` | The receipt key that signed. | | `caller_key_id` | Your retrieval key that signed the request. A receipt presented by any other key is refused — a sponsored render is confirmed only by the caller its search receipt names. | | `sandbox` | Present, and `true`, on every sandbox receipt. | ## What a receipt settles, and what it does not Your request signature is your company's statement that it asked; the receipt is MasterDB's that it answered, and with *which version* of each record. A dispute about an old price is settled by the receipt alone; a dispute about a bill is settled by the receipts, because billing counts exactly what the receipts show. A receipt does not prove the response arrived, and it says nothing about what you did afterwards. There is no signature over the response as a whole: fifty rows from fifty businesses are fifty claims, each carrying its own origin. ## Listing and reconciling `GET /v1/receipts?from=&to=` lists the receipts issued to your own group's keys in a window, oldest first, a hundred to a page with a cursor, narrowed by `key_id` or `ai_company_uuid` if you like. Only ever your own: the key that signs the request decides whose receipts these are. ```typescript title="samples/typescript/receipts.ts" // Receipts: reconcile what you asked for against what MasterDB says it served you. import assert from 'node:assert/strict'; import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { SANDBOX, createEd25519Signer, createRetrievalClient } from '@masterdb/client'; import { KeySet, SANDBOX_ANCHORS, verifyReceipt } from '@masterdb/verifier'; const baseUrl = process.env.MASTERDB_API_URL ?? SANDBOX.retrieval; const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_KEY_FILE as string))); const client = createRetrievalClient({ baseUrl, signer }); const anchors = process.env.MASTERDB_ANCHORS_FILE ? JSON.parse(readFileSync(process.env.MASTERDB_ANCHORS_FILE, 'utf8')) : SANDBOX_ANCHORS; const keys = await KeySet.fetch(baseUrl, { anchors, sandbox: true }); // Make one search now, and keep its receipt as your own record of it. const since = new Date(Date.now() - 60 * 60 * 1000).toISOString(); const search = await client.POST('/v1/search', { body: { collection: 'jobs', filter: { all: [{ country: 'GB' }] }, sort_by: 'published_at:desc', limit: 5 }, }); if (search.error) throw new Error(search.error.code); const mine = verifyReceipt(search.data.receipt, keys); // Every receipt MasterDB issued to your keys in the window, oldest first, 100 to a page. let cursor: string | undefined; let count = 0; let found = false; do { const page = await client.GET('/v1/receipts', { params: { query: { from: since, key_id: signer.keyId, ...(cursor ? { cursor } : {}) } } }); if (page.error) throw new Error(page.error.code); for (const r of page.data.receipts) { const v = verifyReceipt(r.receipt, keys); assert.equal(v.key_id !== undefined, true); if (v.retrieval_id === mine.retrieval_id) found = true; count++; } cursor = page.data.next_cursor ?? undefined; } while (cursor); console.log(`${count} receipts since ${since}; this search's receipt ${mine.retrieval_id} is among them: ${found}`); assert.ok(found); ``` ```python title="samples/python/receipts.py" """Receipts: reconcile what you asked for against what MasterDB says it served you.""" import json import os from datetime import datetime, timedelta, timezone import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, load_key from masterdb_verifier import SANDBOX_ANCHORS, KeySet, parse_strict, verify_receipt base_url = os.environ.get("MASTERDB_API_URL", SANDBOX_API) auth = MasterDBAuth(load_key(os.environ["MASTERDB_KEY_FILE"])) client = httpx.Client(base_url=base_url, auth=auth) anchors = json.load(open(os.environ["MASTERDB_ANCHORS_FILE"])) if "MASTERDB_ANCHORS_FILE" in os.environ else SANDBOX_ANCHORS keys = KeySet.fetch(base_url, anchors=anchors, sandbox=True) # Make one search now, and keep its receipt as your own record of it. since = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat(timespec="milliseconds").replace("+00:00", "Z") search = client.post("/v1/search", json={"collection": "jobs", "filter": {"all": [{"country": "GB"}]}, "sort_by": "published_at:desc", "limit": 5}) search.raise_for_status() mine = verify_receipt(parse_strict(search.content)["receipt"], keys) # Every receipt MasterDB issued to your keys in the window, oldest first, 100 to a page. cursor, count, found = None, 0, False while True: params = {"from": since, "key_id": auth.key_id, **({"cursor": cursor} if cursor else {})} page = client.get("/v1/receipts", params=params) page.raise_for_status() body = parse_strict(page.content) for r in body["receipts"]: v = verify_receipt(r["receipt"], keys) found = found or v.retrieval_id == mine.retrieval_id count += 1 cursor = body.get("next_cursor") if not cursor: break print(count, "receipts since", since, "; this search's receipt", mine.retrieval_id, "is among them:", found) assert found ``` ## In the transparency log Each region folds every minute's receipts, in the order served, into one Merkle tree, and the tree's root goes into MasterDB's transparency log. A receipt's inclusion proof (two levels: the receipt in its minute, the minute in the log) comes from `GET /v1/log/proof`, and checkpoints from `GET /v1/log/checkpoint`. So MasterDB cannot later alter what it asserted it served without it showing to anyone who compares checkpoints ([security model](/threat-model/)). --- # AI policy > What each business permits an AI to do with its data and on its behalf, and the contexts it does not want its data used in — as bits on every row and as a sealed record on every fetch — and the AI-company Terms every delivery travels under. Source: https://docs.masterdb.ai/ai-companies/ai-policy/ Two things govern what you may do with what MasterDB serves: - **The business's AI policy** — its own choices, sealed by a person at the business, as named booleans: how to treat its data as a source, which actions it permits, and the contexts it does not want its data used in. It can change at any time; the version in force when MasterDB served you is the one that governs, and every receipt names it. - **The AI-company Terms** — the agreement your company signed, one versioned text for every AI company, including the use conditions: a record is used once, in one conversation, and is not cached or used for training. ```typescript title="samples/typescript/ai-policy.ts" // The AI policy: what the business permits, and the contexts it does not want its data used in, travels with // every row (as bits) and every fetch (as its sealed record). The key to the bits is public. The AI-company Terms // are one versioned text, apart. import assert from 'node:assert/strict'; import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { SANDBOX, createEd25519Signer, createPublicClient, createRetrievalClient } from '@masterdb/client'; const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_KEY_FILE as string))); const client = createRetrievalClient({ baseUrl: process.env.MASTERDB_API_URL ?? SANDBOX.retrieval, signer }); const publicApi = createPublicClient({ baseUrl: process.env.MASTERDB_API_URL ?? SANDBOX.public }); // The key: every bit of every group, by name, and each blocked context's code (bc1…bc10). Cache it for a day. const key = await publicApi.GET('/v1/ai-policy-key'); if (key.error) throw new Error(key.error.code); const K = key.data; function decode(bits: { ai_policy_schema: number; use: number; action: number; blocked: number }) { if (bits.ai_policy_schema > K.current_ai_policy_schema) throw new Error(`unknown ai_policy_schema ${bits.ai_policy_schema}: refuse rather than guess`); const on = (mask: number, bit: number) => Math.floor(mask / 2 ** bit) % 2 === 1; const terms: Record = {}; for (const e of K.groups.use) terms[e.name] = on(bits.use, e.bit); for (const e of K.groups.action) terms[e.name] = on(bits.action, e.bit); const blocked = K.blocked.filter((c) => c.since_schema <= bits.ai_policy_schema && on(bits.blocked, c.bit)); return { terms, blocked }; } // A row carries the bits in force for its business when it was served. Version 0 is a business with no AI policy // in force (never sealed, or refused at publish): every mask is 0 and nothing is permitted beyond the AI-company // Terms. Take a row whose business has a policy, to compare its bits with the sealed record a fetch carries. const search = await client.POST('/v1/search', { body: { collection: 'products', filter: { all: [{ country: 'US' }] }, sort_by: 'published_at:desc', limit: 20 }, }); if (search.error) throw new Error(search.error.code); const row = search.data.rows.find((r) => r.ai_policy_bits.ai_policy_version > 0); if (row === undefined) { console.log(`none of these ${search.data.rows.length} rows' businesses has an AI policy in force (version 0): nothing is permitted beyond the AI-company Terms`); } else { const fromRow = decode(row.ai_policy_bits); console.log('row', row.record_id, 'AI policy version', row.ai_policy_bits.ai_policy_version, fromRow.terms); for (const c of fromRow.blocked) console.log(`blocked ${c.code} ${c.name}: do not use this business's data to answer in this context`); if (!fromRow.terms['purchase']) console.log('this business does not permit an AI to complete a purchase'); // A fetch carries the business's sealed AI policy record itself: the named booleans, as the business sealed them. const fetched = await client.GET('/v1/records/{record_id}', { params: { path: { record_id: row.record_id } } }); if (fetched.error) throw new Error(fetched.error.code); const policy = fetched.data.ai_policy; if (policy.record === null) { // The fetch carries the policy in force when it is served; with no sealed record there, nothing to compare. console.log(`AI policy version ${policy.version} at fetch: no sealed record, nothing to compare`); } else { const sealed = policy.record as Record; for (const [name, value] of Object.entries(fromRow.terms)) assert.equal(sealed[name], value, `${name}: the bits are derived from the sealed record`); const sealedBlocked = (sealed['blocked'] ?? {}) as Record; assert.deepEqual(fromRow.blocked.map((c) => c.name), K.blocked.filter((c) => sealedBlocked[c.name] === true).map((c) => c.name)); assert.equal(policy.version, row.ai_policy_bits.ai_policy_version); } } // The AI-company Terms in force, and any earlier version a receipt names. // `not_found` means no Terms version is in force in this environment. const terms = await client.GET('/v1/terms'); if (terms.error?.code === 'not_found') { console.log('no AI-company Terms version is in force in this environment'); } else { if (terms.error) throw new Error(terms.error.code); console.log('AI-company Terms version', terms.data.version, 'effective', terms.data.effective_from, terms.data.sha256); const v1 = await client.GET('/v1/terms/{version}', { params: { path: { version: 1 } } }); assert.equal(v1.data?.version, 1); } ``` ```python title="samples/python/ai_policy.py" """The AI policy: what the business permits, and the contexts it does not want its data used in, travels with every row (as bits) and every fetch (as its sealed record). The key to the bits is public. The AI-company Terms are one versioned text, apart.""" import os import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, load_key client = httpx.Client( base_url=os.environ.get("MASTERDB_API_URL", SANDBOX_API), auth=MasterDBAuth(load_key(os.environ["MASTERDB_KEY_FILE"])), ) # The key: every bit of every group, by name, and each blocked context's code (bc1…bc10). Public, unsigned # (the sandbox serves the public paths on its API host). Cache it for a day. key = httpx.get(os.environ.get("MASTERDB_API_URL", SANDBOX_API) + "/v1/ai-policy-key").json() def decode(bits: dict) -> tuple[dict[str, bool], list[dict]]: if bits["ai_policy_schema"] > key["current_ai_policy_schema"]: raise ValueError(f"unknown ai_policy_schema {bits['ai_policy_schema']}: refuse rather than guess") terms = {} for group in ("use", "action"): for e in key["groups"][group]: terms[e["name"]] = bool(bits[group] >> e["bit"] & 1) blocked = [c for c in key["blocked"] if c["since_schema"] <= bits["ai_policy_schema"] and bits["blocked"] >> c["bit"] & 1] return terms, blocked # A row carries the bits in force for its business when it was served. Version 0 is a business with no AI policy # in force (never sealed, or refused at publish): every mask is 0 and nothing is permitted beyond the AI-company # Terms. Take a row whose business has a policy, to compare its bits with the sealed record a fetch carries. search = client.post("/v1/search", json={"collection": "products", "filter": {"all": [{"country": "US"}]}, "sort_by": "published_at:desc", "limit": 20}) search.raise_for_status() rows = search.json()["rows"] row = next((r for r in rows if r["ai_policy_bits"]["ai_policy_version"] > 0), None) if row is None: print(f"none of these {len(rows)} rows' businesses has an AI policy in force (version 0): nothing is permitted beyond the AI-company Terms") else: from_row, blocked = decode(row["ai_policy_bits"]) print("row", row["record_id"], "AI policy version", row["ai_policy_bits"]["ai_policy_version"], from_row) for c in blocked: print(f"blocked {c['code']} {c['name']}: do not use this business's data to answer in this context") if not from_row["purchase"]: print("this business does not permit an AI to complete a purchase") # A fetch carries the business's sealed AI policy record itself: the named booleans, as the business sealed them. fetched = client.get(f"/v1/records/{row['record_id']}") fetched.raise_for_status() policy = fetched.json()["ai_policy"] if policy["record"] is None: # The fetch carries the policy in force when it is served; with no sealed record there, nothing to compare. print(f"AI policy version {policy['version']} at fetch: no sealed record, nothing to compare") else: assert all(policy["record"][name] == value for name, value in from_row.items()), "the bits are derived from the sealed record" sealed_blocked = policy["record"].get("blocked", {}) assert [c["name"] for c in blocked] == [c["name"] for c in key["blocked"] if sealed_blocked.get(c["name"]) is True] assert policy["version"] == row["ai_policy_bits"]["ai_policy_version"] # The AI-company Terms in force, and any earlier version a receipt names. # `not_found` means no Terms version is in force in this environment. terms = client.get("/v1/terms") if terms.status_code == 404 and terms.json().get("code") == "not_found": print("no AI-company Terms version is in force in this environment") else: terms.raise_for_status() current = terms.json() print("AI-company Terms version", current["version"], "effective", current["effective_from"], current["sha256"]) assert client.get("/v1/terms/1").json()["version"] == 1 ``` ## The AI policy, on a row Every row carries `ai_policy_bits: {ai_policy_version, ai_policy_schema, use, action, blocked}`, stamped as it is served from the business's current sealed record, so you can honour what the business permits without fetching. `use`, `action` and `blocked` are bitmasks derived from the named booleans; `ai_policy_schema` says which name each bit is. Under `ai_policy_schema` 2, bit *n* (value `2^n`) is: | Bit | `use` | `action` | `blocked` | |---|---|---|---| | 0 | `cite_as_source` | `answer` | `bc1` adult_sexual | | 1 | `definitive_source` | `quote` | `bc2` alcohol | | 2 | `prefer_over_inference` | `reserve` | `bc3` crime_illegal | | 3 | `include_in_recommendations` | `purchase` | `bc4` death_tragedy_disaster | | 4 | `quote_policy_verbatim` | `contact` | `bc5` firearms_weapons_violence | | 5 | `prices_indicative` | `hand_to_human` | `bc6` gambling_betting | | 6 | `state_publish_date` | — | `bc7` mental_health_self_harm | | 7 | — | — | `bc8` politics_elections | | 8 | — | — | `bc9` regulated_advice | | 9 | — | — | `bc10` tobacco_vaping_drugs | The full key — each code's number, name, plain-English meaning, bit and the schema it arrived in — is public at [`GET /v1/ai-policy-key`](/reference/ai-policy-key/), in `@masterdb/shared` and in both verifier libraries. Bit positions under an `ai_policy_schema` never change; a new toggle is a new `ai_policy_schema` that only appends, and **a blocked-context code is never reused or renamed**. **If you meet an `ai_policy_schema` you do not know, refuse to act on the bits** rather than guess — and fetch the record, whose named booleans are self-describing. `ai_policy_schema` 1 has no blocked contexts: its `blocked` mask is 0. A business that has never sealed an AI policy is version 0 with every mask 0. ## The blocked contexts A set `blocked` bit means: **do not use this business's data to build a response in that context.** It is a suppression list the business chose, not a rating of the business — a toy shop may block `bc1` adult_sexual and `bc6` gambling_betting so that its products are never drawn into those conversations, while still being found for a birthday-present search. The ten contexts are those of the Business Portal's AI policy screen, in a fixed order: | Code | Name | The business does not want its data used to answer about | |---|---|---| | `bc1` | `adult_sexual` | adult and sexual content | | `bc2` | `alcohol` | alcohol | | `bc3` | `crime_illegal` | crime and illegal activity | | `bc4` | `death_tragedy_disaster` | death, tragedy and disaster | | `bc5` | `firearms_weapons_violence` | firearms, weapons and violence | | `bc6` | `gambling_betting` | gambling and betting | | `bc7` | `mental_health_self_harm` | mental health, self-harm and crisis | | `bc8` | `politics_elections` | politics and elections | | `bc9` | `regulated_advice` | regulated advice (medical, legal or financial) | | `bc10` | `tobacco_vaping_drugs` | tobacco, vaping and recreational drugs | Deciding whether a conversation is in one of these contexts is yours to do; the policy tells you what the business asked. ## The AI policy, on a fetch A fetch carries the sealed record itself: `ai_policy: {version, record, seal}`, where `record` holds the named booleans — the ten blocked contexts in the nested `blocked` group — and `seal` is the business's seal over them. A business that has never sealed an AI policy answers `{version: 0, record: null, seal: null}` and zero bits: nothing is affirmatively permitted beyond the AI-company Terms. The sealed record is what proves a row's bits: derive them from its exact bytes (the `ai_policy.record` member as served, never a re-serialisation) and compare. The verifier libraries do it (`verifyAiPolicyBits` / `verify_ai_policy_bits`), and so does [`POST /v1/verify`](/ai-companies/verify/) when you send the record and its seal with the row's `ai_policy_bits`. The one difference allowed is `purchase`, which MasterDB withholds on rows while the business's authorised endpoints are suspended. What the use and action names mean: | Name | When true, the business says | |---|---| | `answer` | you may use this data to answer (on by default) | | `quote` | you may state a price as the business's current price (on by default) | | `reserve` | you may make a booking or reservation on a person's behalf | | `purchase` | you may complete a purchase on a person's behalf, through a checkout domain the business has authorised | | `contact` | you may contact the business on a person's behalf | | `hand_to_human` | you must hand the person to the business for anything beyond answering (on by default) | | `cite_as_source` | cite the business as the source | | `definitive_source` | where its record conflicts with a third party, its record wins | | `prefer_over_inference` | a field it left out means *not stated*, not *unknown*: do not infer or reconstruct it from other sources | | `include_in_recommendations` | it may be included in recommendations | | `quote_policy_verbatim` | quote its delivery, returns and warranty wording verbatim rather than paraphrase it | | `prices_indicative` | its prices are indicative | | `state_publish_date` | say when the information was last published, rather than implying it is current | Action terms and blocked contexts are honoured by the counterparty — you. Nothing cryptographic can stop a system that ignores `purchase: false` or a blocked context; that is a breach of the AI-company Terms, and the receipts and the evidence they give are the remedy. An AI policy record of the earlier form reads `{"schema": "masterdb/terms/1", "terms_schema": 1, …}`: the same use and action booleans, no blocked contexts. It is served and verified exactly as sealed. ## Which version governs The AI policy that governs a delivery is the business's sealed policy in force at `served_at`, and the receipt names the version applied to each row (`ai_policy_version`). If a business tightens its policy and a region has not yet received the change, the version on your receipt governs: a lag is MasterDB's incident, never your breach. ## The AI-company Terms `GET /v1/terms` answers the Terms in force — `version`, `effective_from`, `sha256` and the `text` — and `GET /v1/terms/{version}` any earlier version, so the version a receipt names can always be read in the words that governed it. --- # Blocking is invisible > A business may block an AI company's group. On MasterDB's authenticated surfaces the blocked company sees fewer rows and nothing else — and what that does and does not hide. Source: https://docs.masterdb.ai/ai-companies/blocking/ A business may block an **AI group** — a company and its affiliates, never a single key. The block is in force in every region within seconds, for every key in the group, including keys registered later. From then on: - its rows are absent from your searches; - its records answer a fetch exactly as a record that never existed: the same `404`, the same problem body, no sooner than 20 ms after arrival; - **nothing says so.** No field, no count, no error, no band or aggregate anywhere in the API or the AI Portal that could be differenced. Your usage has no figure about blocks. A business whose records MasterDB has taken out of serving, and a record its business withdrew or deleted, look the same from outside. ```typescript title="samples/typescript/blocking.ts" // Blocking is invisible: a business that blocked your group is simply absent. Nothing says so — // not a field, not a count, not an error — and its records answer exactly as a record that never existed. import assert from 'node:assert/strict'; import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { SANDBOX, createEd25519Signer, createRetrievalClient } from '@masterdb/client'; const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_KEY_FILE as string))); const client = createRetrievalClient({ baseUrl: process.env.MASTERDB_API_URL ?? SANDBOX.retrieval, signer }); // In the sandbox corpus, Saffron Mill (a fictional business in GB, IE and US) blocks the group of // "MasterDB Sandbox AI", the sandbox AI company, so that blocking can be seen from the AI side. const SAFFRON_MILL = '0c148394-9635-498d-84bc-f4a785a948c5'; const saffronProduct = 'mdb_34gueyd2qiuyxomxzrixasiunt'; const neverPublished = 'mdb_aaaaaaaaaaaaaaaaaaaaaaaaaa'; const blocked = await client.GET('/v1/records/{record_id}', { params: { path: { record_id: saffronProduct } }, parseAs: 'text' }); const absent = await client.GET('/v1/records/{record_id}', { params: { path: { record_id: neverPublished } }, parseAs: 'text' }); console.log(blocked.response.status, blocked.error); console.log(absent.response.status, absent.error); assert.equal(blocked.response.status, 404); assert.equal(absent.response.status, 404); // The same problem body; its `instance` only echoes the path you asked for. const { instance: _a, ...blockedBody } = blocked.error as Record; const { instance: _b, ...absentBody } = absent.error as Record; assert.deepEqual(blockedBody, absentBody); // Search results never include its rows, and nothing in the response counts what is missing. const search = await client.POST('/v1/search', { body: { collection: 'products', filter: { all: [{ country: 'GB' }] }, sort_by: 'published_at:desc', limit: 50 }, }); if (search.error) throw new Error(search.error.code); assert.ok(search.data.rows.every((row) => row.business_uuid !== SAFFRON_MILL)); assert.deepEqual(Object.keys(search.data).sort(), ['collection', 'country', 'receipt', 'retrieval_id', 'rows'].sort()); console.log(`${search.data.rows.length} rows in GB, none from a business that blocked this group`); ``` ```python title="samples/python/blocking.py" """Blocking is invisible: a business that blocked your group is simply absent. Nothing says so — not a field, not a count, not an error — and its records answer exactly as a record that never existed.""" import os import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, load_key client = httpx.Client( base_url=os.environ.get("MASTERDB_API_URL", SANDBOX_API), auth=MasterDBAuth(load_key(os.environ["MASTERDB_KEY_FILE"])), ) # In the sandbox corpus, Saffron Mill (a fictional business in GB, IE and US) blocks the group of # "MasterDB Sandbox AI", the sandbox AI company, so that blocking can be seen from the AI side. SAFFRON_MILL = "0c148394-9635-498d-84bc-f4a785a948c5" saffron_product = "mdb_34gueyd2qiuyxomxzrixasiunt" never_published = "mdb_aaaaaaaaaaaaaaaaaaaaaaaaaa" blocked = client.get(f"/v1/records/{saffron_product}") absent = client.get(f"/v1/records/{never_published}") print(blocked.status_code, blocked.json()) print(absent.status_code, absent.json()) assert blocked.status_code == absent.status_code == 404 # The same problem body; its `instance` only echoes the path you asked for. strip = lambda problem: {k: v for k, v in problem.items() if k != "instance"} # noqa: E731 assert strip(blocked.json()) == strip(absent.json()) # Search results never include its rows, and nothing in the response counts what is missing. search = client.post("/v1/search", json={"collection": "products", "filter": {"all": [{"country": "GB"}]}, "sort_by": "published_at:desc", "limit": 50}) search.raise_for_status() result = search.json() assert all(row["business_uuid"] != SAFFRON_MILL for row in result["rows"]) assert sorted(result) == ["collection", "country", "receipt", "retrieval_id", "rows"] print(len(result["rows"]), "rows in GB, none from a business that blocked this group") ``` ## Why nothing says so A business may not want to tell an AI company it has chosen not to be represented by it, and MasterDB chooses the business's silence over the AI company's certainty. For the same reason, MasterDB offers **no completeness proof**: a proof that "these are all the rows" would let a blocked company detect the exclusion by arithmetic. ## The limits A block is hidden on MasterDB's surfaces. From outside MasterDB it can be inferred: by comparing results with another AI company, from a business's own website, or by presenting a record you hold to the public verify endpoint (which answers for anyone who has the record). ## What it means for your integration Treat every absent result the same way: the record is not available to you. Do not retry a `404` in the hope of a different answer, and do not build logic that assumes a business's catalogue is complete in your results. --- # Usage and billing > What counts as a billable query, how your usage is reported, how the prepaid stop works, and what is never billed. Source: https://docs.masterdb.ai/ai-companies/usage-and-billing/ ## What is billed MasterDB bills what it observed itself serve: | Billed: one query | Never billed | |---|---| | a search that returns at least one row | a search that returns no rows | | a fetch that returns a record | a fetch answered `404` | | | a refusal (any `4xx`: a bad signature, an invalid query, a rate limit) | | | MasterDB's own failures (any `5xx`) | Each signed request is billed once — counted by its `(keyid, nonce)` — whichever and however many regions answered it. Zero-result searches are free and carry no restriction beyond the ordinary rate limit: explore freely. Queries are priced from your company's **rate card** in marginal tiers over the billing period: each tier's rate applies only to the queries inside it, and the first tier is a free allowance. Your rate card is shown in the AI Portal. Receipts are the record: the bill is the receipts, counted. ## Usage `GET /v1/usage` answers your group's usage: searches, fetches and billable queries by day, key, legal entity and collection, and where the period stands in your tiers. It never contains any figure about blocks — no count, no band. ```typescript title="samples/typescript/usage.ts" // Usage: your queries by day, key, legal entity and collection, and where you are in your tiers. import assert from 'node:assert/strict'; import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { SANDBOX, createEd25519Signer, createRetrievalClient } from '@masterdb/client'; const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_KEY_FILE as string))); const client = createRetrievalClient({ baseUrl: process.env.MASTERDB_API_URL ?? SANDBOX.retrieval, signer }); const usage = await client.GET('/v1/usage'); if (usage.error) throw new Error(usage.error.code); console.log('as at', usage.data.as_at); for (const d of usage.data.days) console.log(d.date, d.collection, 'searches', d.searches, 'fetches', d.fetches, 'billable', d.billable); for (const t of usage.data.tiers ?? []) console.log('tier', t.tier, t.from_queries, '-', t.to_queries, 'consumed', t.consumed); // There is no figure about blocks anywhere in usage: not a count, not a band. assert.ok(!JSON.stringify(usage.data).includes('block')); ``` ```python title="samples/python/usage.py" """Usage: your queries by day, key, legal entity and collection, and where you are in your tiers.""" import os import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, load_key client = httpx.Client( base_url=os.environ.get("MASTERDB_API_URL", SANDBOX_API), auth=MasterDBAuth(load_key(os.environ["MASTERDB_KEY_FILE"])), ) usage = client.get("/v1/usage") usage.raise_for_status() data = usage.json() print("as at", data["as_at"]) for d in data["days"]: print(d["date"], d["collection"], "searches", d["searches"], "fetches", d["fetches"], "billable", d["billable"]) for t in data["tiers"]: print("tier", t["tier"], t["from_queries"], "-", t["to_queries"], "consumed", t["consumed"]) # There is no figure about blocks anywhere in usage: not a count, not a band. assert "block" not in usage.text ``` ## Paying A company pays by prepayment (card or bank transfer), or, once approved, by monthly invoice. Advertising revenue MasterDB has collected on your behalf is netted against what you owe, monthly, and each month closes with a statement. **The prepaid stop.** A prepay company whose allowance is spent is answered `402` with the code `allowance_exhausted`. A small overrun at the moment the allowance runs out is possible, and is charged. If your allowance cannot be confirmed, requests are answered `503` `unavailable` with `Retry-After` until it can: the stop fails closed, and those `503`s are not billed. ## In the sandbox Billing is computed and shown on the usage screens, and never invoiced or paid. Rate limits are the same as in production ([Rate limits](/ai-companies/rate-limits-and-errors/#rate-limits)). --- # Ads and sponsored items > Ask for an ad pool, show what you choose with its label, and confirm each render within ten minutes and each click within 24 hours — plus sponsored and promoted rows in ordinary search, confirmed against the search's receipt. Net prices only. Source: https://docs.masterdb.ai/ai-companies/ads/ Paid items reach you in two ways: - **Ads**, from a pool you ask for: `POST /v1/ads/pool` with a country and a few subject keywords. - **Sponsored and promoted rows** inside an ordinary search: a product, event, job or update a business pays to have marked, served in the order you asked for and carrying `sponsored: true`. Nothing is shown for you. You choose what to show, show it with its label, and tell MasterDB what you showed: `POST /v1/ads/render` for each render, `POST /v1/ads/click` for each click. MasterDB pays you the **net price** of each billed confirmation. Paid items never change the order of a search. ```typescript title="samples/typescript/ads.ts" // Ads and sponsored items: ask for a pool, show what you choose with its label, confirm each render and click. import assert from 'node:assert/strict'; import { createPrivateKey, randomUUID } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { SANDBOX, createEd25519Signer, createRetrievalClient } from '@masterdb/client'; import { KeySet, SANDBOX_ANCHORS, verifyReceipt } from '@masterdb/verifier'; const baseUrl = process.env.MASTERDB_API_URL ?? SANDBOX.retrieval; const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_KEY_FILE as string))); const client = createRetrievalClient({ baseUrl, signer }); const anchors = process.env.MASTERDB_ANCHORS_FILE ? JSON.parse(readFileSync(process.env.MASTERDB_ANCHORS_FILE, 'utf8')) : SANDBOX_ANCHORS; const keys = await KeySet.fetch(baseUrl, { anchors, sandbox: true }); // Your own opaque reference for the conversation: never a user id, never the question. const sessionRef = `conv-${randomUUID()}`; // The pool: one country, a few subject keywords (an exact filter), the formats you can show, and your order. const poolBody = { country: 'US', keywords: ['wireless headphones'], formats: ['card', 'compact'] as ('card' | 'compact')[], sort_by: 'net_price:desc', max_per_campaign: 1, size: 5, session_ref: sessionRef }; const pool = await client.POST('/v1/ads/pool', { body: poolBody }); if (pool.error) throw new Error(`${pool.error.code}: ${pool.error.detail ?? ''}`); verifyReceipt(pool.data.receipt, keys); assert.ok(pool.data.ads.length > 0, 'the sandbox has a live ad for these keywords'); const ad = pool.data.ads[0] as (typeof pool.data.ads)[number]; console.log(`[${ad.label}] ${ad.creative.headline} (${ad.creative.display_domain}) — you earn ${ad.net_price.amount} ${ad.net_price.currency} ${ad.net_price.basis}`); assert.equal(ad.label, 'Ad'); // Show it with its label. Fetch the images from your servers with the signed links (fifteen minutes), never // from the person's browser. Then confirm the render within ten minutes, in a format the ad offers. const format = ad.formats?.includes('card') ? 'card' : 'compact'; const renderKey = randomUUID(); const confirmRender = (idempotencyKey: string) => client.POST('/v1/ads/render', { params: { header: { 'Idempotency-Key': idempotencyKey } }, body: { render_token: ad.render_token, format, session_ref: sessionRef } }); const render = await confirmRender(renderKey); if (render.error) throw new Error(`${render.error.code}: ${render.error.detail ?? ''}`); assert.equal(render.data.accepted, true); assert.ok(render.data.click_token); // A retry with the same Idempotency-Key gets the first answer; any other second confirmation is token_reused. const retried = await confirmRender(renderKey); assert.deepEqual(retried.data, render.data); const replayed = await confirmRender(randomUUID()); assert.deepEqual(replayed.data, { accepted: false, reason: 'token_reused' }); // Rendered once in this conversation, the ad is left out of its pools for the next 60 minutes. const next = await client.POST('/v1/ads/pool', { body: poolBody }); if (next.error) throw new Error(next.error.code); assert.ok(!next.data.ads.some((a) => a.ad_id === ad.ad_id)); // The person clicked: confirm with the click token (valid 24 hours), then send them to the destination yourself. const click = await client.POST('/v1/ads/click', { params: { header: { 'Idempotency-Key': randomUUID() } }, body: { click_token: render.data.click_token, session_ref: sessionRef }, }); if (click.error) throw new Error(`${click.error.code}: ${click.error.detail ?? ''}`); assert.equal(click.data.accepted, true); console.log('click confirmed; open', ad.creative.destination_url); // A sponsored row in an ordinary search carries `sponsored: true`. Confirm its render against that search's receipt. const search = await client.POST('/v1/search', { body: { collection: 'products', filter: { all: [{ country: 'US' }, { vertical: 'electronics' }] }, sort_by: 'published_at:desc', limit: 50 }, }); if (search.error) throw new Error(search.error.code); const row = search.data.rows.find((r) => r['sponsored'] === true); assert.ok(row, 'the sandbox sponsors one electronics product in the US'); const sponsored = await client.POST('/v1/ads/render', { params: { header: { 'Idempotency-Key': randomUUID() } }, body: { retrieval_id: search.data.retrieval_id, record_id: row.record_id, receipt: search.data.receipt, format: 'card', session_ref: sessionRef }, }); if (sponsored.error) throw new Error(`${sponsored.error.code}: ${sponsored.error.detail ?? ''}`); console.log(`[${sponsored.data.label}] ${String(row['product_name'])}: render accepted ${sponsored.data.accepted}`); assert.equal(sponsored.data.accepted, true); assert.equal(sponsored.data.label, 'Sponsored'); // A row the search served unmarked is never paid for: its confirmation is refused. const plain = search.data.rows.find((r) => r['sponsored'] !== true); if (plain) { const refused = await client.POST('/v1/ads/render', { params: { header: { 'Idempotency-Key': randomUUID() } }, body: { retrieval_id: search.data.retrieval_id, record_id: plain.record_id, receipt: search.data.receipt, format: 'card', session_ref: sessionRef }, }); assert.equal(refused.error?.code, 'request_invalid'); } ``` ```python title="samples/python/ads.py" """Ads and sponsored items: ask for a pool, show what you choose with its label, confirm each render and click.""" import json import os import uuid import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, load_key from masterdb_verifier import SANDBOX_ANCHORS, KeySet, parse_strict, verify_receipt base_url = os.environ.get("MASTERDB_API_URL", SANDBOX_API) client = httpx.Client(base_url=base_url, auth=MasterDBAuth(load_key(os.environ["MASTERDB_KEY_FILE"]))) anchors = json.load(open(os.environ["MASTERDB_ANCHORS_FILE"])) if "MASTERDB_ANCHORS_FILE" in os.environ else SANDBOX_ANCHORS keys = KeySet.fetch(base_url, anchors=anchors, sandbox=True) def post(path: str, body: dict, idempotency_key: str | None = None) -> httpx.Response: headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {} return client.post(path, json=body, headers=headers) def ok(res: httpx.Response) -> dict: if res.status_code != 200: problem = res.json() raise SystemExit(f"{problem['code']}: {problem.get('detail', '')}") return parse_strict(res.content) # Your own opaque reference for the conversation: never a user id, never the question. session_ref = f"conv-{uuid.uuid4()}" # The pool: one country, a few subject keywords (an exact filter), the formats you can show, and your order. pool_body = { "country": "US", "keywords": ["wireless headphones"], "formats": ["card", "compact"], "sort_by": "net_price:desc", "max_per_campaign": 1, "size": 5, "session_ref": session_ref, } pool = ok(post("/v1/ads/pool", pool_body)) verify_receipt(pool["receipt"], keys) assert pool["ads"], "the sandbox has a live ad for these keywords" ad = pool["ads"][0] price = ad["net_price"] print(f"[{ad['label']}] {ad['creative']['headline']} ({ad['creative']['display_domain']}) — you earn {price['amount']} {price['currency']} {price['basis']}") assert ad["label"] == "Ad" # Show it with its label. Fetch the images from your servers with the signed links (fifteen minutes), never # from the person's browser. Then confirm the render within ten minutes, in a format the ad offers. fmt = "card" if "card" in ad.get("formats", []) else "compact" render_key = str(uuid.uuid4()) def confirm_render(idempotency_key: str) -> dict: return ok(post("/v1/ads/render", {"render_token": ad["render_token"], "format": fmt, "session_ref": session_ref}, idempotency_key)) render = confirm_render(render_key) assert render["accepted"] is True and "click_token" in render # A retry with the same Idempotency-Key gets the first answer; any other second confirmation is token_reused. assert confirm_render(render_key) == render assert confirm_render(str(uuid.uuid4())) == {"accepted": False, "reason": "token_reused"} # Rendered once in this conversation, the ad is left out of its pools for the next 60 minutes. following = ok(post("/v1/ads/pool", pool_body)) assert all(a["ad_id"] != ad["ad_id"] for a in following["ads"]) # The person clicked: confirm with the click token (valid 24 hours), then send them to the destination yourself. click = ok(post("/v1/ads/click", {"click_token": render["click_token"], "session_ref": session_ref}, str(uuid.uuid4()))) assert click["accepted"] is True print("click confirmed; open", ad["creative"]["destination_url"]) # A sponsored row in an ordinary search carries `sponsored: true`. Confirm its render against that search's receipt. search = ok( post( "/v1/search", {"collection": "products", "filter": {"all": [{"country": "US"}, {"vertical": "electronics"}]}, "sort_by": "published_at:desc", "limit": 50}, ) ) row = next((r for r in search["rows"] if r.get("sponsored") is True), None) assert row is not None, "the sandbox sponsors one electronics product in the US" sponsored_body = {"retrieval_id": search["retrieval_id"], "record_id": row["record_id"], "receipt": search["receipt"], "format": "card", "session_ref": session_ref} sponsored = ok(post("/v1/ads/render", sponsored_body, str(uuid.uuid4()))) print(f"[{sponsored['label']}] {row['product_name']}: render accepted {sponsored['accepted']}") assert sponsored["accepted"] is True and sponsored["label"] == "Sponsored" # A row the search served unmarked is never paid for: its confirmation is refused. plain = next((r for r in search["rows"] if r.get("sponsored") is not True), None) if plain is not None: refused = post("/v1/ads/render", {**sponsored_body, "record_id": plain["record_id"]}, str(uuid.uuid4())) assert refused.status_code == 400 and refused.json()["code"] == "request_invalid" ``` In the sandbox, ads are served from fictional budgets, with real tokens and no money: the corpus's US electronics business advertises headphones under `wireless headphones`, and one of its products is sponsored. ## The flow 1. **Ask for a pool** for one country and a few subject keywords, in your own order. 2. **Choose** what to show — none, one or several — and fetch the images on your servers. 3. **Show each one with its label** (`Ad`), in a format it offers. 4. **Confirm each render** within ten minutes of the pool, with its render token. The answer carries a **click token**. 5. **If the person clicks,** confirm the click with the click token within 24 hours, and send them to the destination yourself. Every call is a signed request with your retrieval key and `tag="mdb-retrieval"`, exactly as a search is ([signing requests](/ai-companies/signing-requests/)). The MCP server, `masterdb-mcp`, offers the same calls as the `ads_pool` and `report` tools ([MCP](/ai-companies/mcp/)). ## The pool ```json { "country": "US", "keywords": ["wireless headphones"], "formats": ["card", "compact"], "sort_by": "net_price:desc", "max_per_campaign": 1, "size": 5, "session_ref": "conv-5b0c9a4e" } ``` | Member | | |---|---| | `country` | The person's country (ISO 3166-1 alpha-2), as you know it. `subdivision` (ISO 3166-2) too, if you know it. | | `keywords` | 1 to 10 **subject terms**, matched exactly against the ads' keywords — a filter, never a text query. Never the person's question, never a user or conversation id. | | `formats` | The render formats you can show: `compact`, `card` or both. | | `sort_by` | **Mandatory**, as in search: up to three of `net_price`, `published_at`, `keyword_matches` and `random` (seeded by the pool's `retrieval_id`), each `:asc` or `:desc`. MasterDB never supplies an order. | | `max_per_campaign` | Optional: at most this many ads from one campaign. | | `size` | Up to 20; default 20. | | `session_ref` | **Required.** Your opaque reference for the conversation, for the repeat rules below. HMACed on arrival, kept 24 hours, never shown to a business. | The answer is up to `size` ads — possibly none, when nothing payable matches — with a `retrieval_id` and a **receipt** like every other retrieval ([receipts](/ai-companies/receipts/)). Each ad carries: | Member | | |---|---| | `ad_id`, `campaign_id`, `business_uuid` | Which ad, which campaign, whose. | | `label` | `Ad`. Show it beside the ad. | | `formats` | The formats it offers among those you asked for: `compact` needs its square image, `card` its landscape one. | | `creative` | `headline`, `description`, `cta`, `destination_url`, `display_domain`, and signed links to its images, `image_square_url` (1:1, for `compact`) and `image_landscape_url` (1.91:1, for `card`), valid fifteen minutes. | | `net_price` | What you earn: `{amount, currency: "USD", basis}`, per thousand renders (`cpm`) or per click (`cpc`). | | `adl_origin_seal` | The business's seal over the ad as it was published. | | `record_base64` | The exact bytes the business sealed (`masterdb/ads/1`), base64. | | `row` | The ad's signed index row: the seal inline (`adl_origin_seal`), the destination (`adl_dest`), a hash per image (`adl_creative`), and the business's `ai_policy_bits`, as on every search row. | | `ai_policy` | The business's sealed AI policy, `{version, record_base64, seal}` — as every fetch carries it — which proves the bits on `row`. Version 0 with nulls: it has sealed none. | | `render_token` | A signed, single-use token: what you confirm the render with. | ### Checking an ad before you show it An ad is rendered alone, so each item carries everything needed to check it, with no further request: that MasterDB signed `row`, that `row.adl_origin` is the SHA-256 of `record_base64`, that the business's seal covers those bytes, and that every text and link in `creative` is the one the business sealed (`cta` is the sealed `cta_text`). Hash each image you fetch against `row.adl_creative`. The libraries do all of it from public data — MasterDB's key set and the business's keys as published beside its certificate: ```ts const published = await (await fetch(`https://verify.masterdb.ai/v1/certificates/${ad.business_uuid}/keys`)).json(); verifyAdItem(ad, keySet, { seal: { publishedKeys: published, keySet } }); // throws VerificationError: ad_mismatch, hash_mismatch, … verifyAdImage(ad.row, squareImageBytes); ``` `POST /v1/verify` with `{"ad_item": }` answers the same question, signed ([verify](/ai-companies/verify/)). An ad that fails is not one to show. **Net only.** The price in the pool is what you receive after MasterDB's take. The gross price and the take rate never leave MasterDB, not in the pool, a token or a confirmation. How you weigh relevance against revenue is your decision; there is no auction. **A pool holds budget.** Each campaign in a pool reserves a little of its budget — the cost of one render, or the expected cost of one click — for ten minutes. At most three reserves per key per campaign are outstanding at once, and past 600 pools an hour a key may ask for at most 20 pools per render it confirms (`rate_limited` otherwise). Ask for a pool when you are about to answer, not speculatively. ### Formats `compact` and `card` are two ways to render the same ad, not two kinds of ad: | Format | | |---|---| | `compact` | A small square image beside the headline, the description and a text link, in the flow of your answer. | | `card` | A self-contained card: the wide image above the headline, the description and a button with the call to action. Beside or below your answer. | You choose the format and state it when you confirm the render; it must be one the ad offers. ## Showing a paid item Every paid item is shown with its label, beside it, every time: | Item | Label | Where it comes from | |---|---|---| | An ad | `Ad` | the pool | | A sponsored product | `Sponsored` | a `products` search row with `sponsored: true` | | A promoted event | `Promoted event` | an `events` search row with `sponsored: true` | | A promoted job | `Promoted vacancy` | a `jobs` search row with `sponsored: true` | | A promoted update | `Promoted update` | an `updates` search row with `sponsored: true` | Show the creative as the business published it: its headline, description, call to action and `display_domain`, which is the destination's own host — so the domain the person sees is the domain the click lands on. The `destination_url` is delivered exactly as the business entered it, query string included; the business measures its results with it. Link to it directly. ## Confirming a render Within **ten minutes** of the pool, on MasterDB's clock: ```json { "render_token": { "payloadType": "application/vnd.masterdb.render.v1+json", "payload": "…", "signatures": [ … ] }, "format": "card", "session_ref": "conv-5b0c9a4e" } ``` Send an `Idempotency-Key` header with every confirmation. A confirmation MasterDB can read is answered `200`, saying whether the render was accepted: | Answer | Meaning | |---|---| | `{"accepted": true, "click_token": {…}}` | Recorded. Keep the click token for this item. | | `{"accepted": false, "reason": "window_expired"}` | More than ten minutes after the pool: recorded, never billed, never paid. | | `{"accepted": false, "reason": "budget_exhausted"}` | The campaign's money ran out: recorded, never billed, never paid. | | `{"accepted": false, "reason": "token_reused"}` | This token was already confirmed. | **Retries.** A token is confirmed once. A retry with the **same** `Idempotency-Key` gets the first answer again, click token included, so a confirmation lost to a timeout is safe to repeat. A second confirmation with any other key is `token_reused`: recorded, never billed. A token that MasterDB did not sign, or that was issued to another AI company, is refused `400` `token_invalid`; a format the ad does not offer is `request_invalid`. Confirm wherever your requests land: MasterDB routes the confirmation to the region that served the pool. If it cannot, the answer is `503` `unavailable` — retry with the same `Idempotency-Key`. ### Repeats in one conversation `session_ref` is how MasterDB applies the per-conversation rules, and it is only as good as you make it: one value per conversation, opaque, never a user id. - An item rendered in a conversation is **left out of that conversation's pools for 60 minutes**. - Rendered again in the same conversation, it is accepted and recorded, but **neither charged nor paid** — unless its advertiser allows repeats, in which case each render is charged, up to the advertiser's own maximum per conversation. - A repeat **click** on the same item from the same conversation within 15 minutes is recorded and not billed. ## Confirming a click When the person clicks, send them to `destination_url` yourself — MasterDB is never in the redirect path — and confirm the click with the click token from the render confirmation and the same `session_ref`: ```json { "click_token": { "payloadType": "application/vnd.masterdb.click.v1+json", "payload": "…", "signatures": [ … ] }, "session_ref": "conv-5b0c9a4e" } ``` The click token is valid for **24 hours** from the render confirmation. A later click is answered `{"accepted": false, "reason": "window_expired"}`: recorded, never billed. Clicks are billed only on campaigns that pay per click (`net_price.basis` `cpc`); on a campaign that pays per thousand renders, and on every promotion, a click is accepted and recorded but not billed — you were paid for the render. ## Sponsored and promoted rows in search A business may sponsor its products, or promote an event, a job or an update. Such a row is served in an ordinary search, in the order you asked for, with `sponsored: true` — and its entry in the search's receipt says `sponsored: true` too. It is paid for exactly like an ad: show it with its label, and confirm the render with the search's `retrieval_id`, the row's id and the search's receipt, **exactly as you received it**: ```json { "retrieval_id": "r_…", "record_id": "mdb_…", "receipt": { "payloadType": "application/vnd.masterdb.receipt.v3+json", "payload": "…", "signatures": [ … ] }, "format": "card", "session_ref": "conv-5b0c9a4e" } ``` MasterDB checks the receipt is its own, reserves and confirms the render in one step, and answers with the click token and the label to show: ```json { "accepted": true, "label": "Sponsored", "click_token": { … } } ``` - The same ten-minute window runs from the search's `served_at`; the same answers apply (`window_expired`, `budget_exhausted` — the campaign has no money left in the region, and nothing is billed — and `token_reused` for a second confirmation of the same row of the same search, with the same retry rule). - The receipt must be format 2 and **issued to the key making the confirmation** (`caller_key_id`): a receipt from another key or another company is `token_invalid`. So is a receipt MasterDB did not sign. - A row the receipt shows served **without** the marker is never paid for: its confirmation is refused `request_invalid`. - `sponsored` is set as a row is served and is not part of the row's signature; `verifyServedRow` removes it before checking ([verify](/ai-companies/verify/)). Confirm a click on a sponsored or promoted row with its click token, as for an ad. ## What you must never do - **Never fetch a creative from the person's browser or device.** The signed image links are for your servers: fetch them there, within their fifteen minutes, and serve the images yourself. A link passed to a browser would hand the person's IP address to MasterDB's CDN. - **Never send the conversation.** Keywords are subject terms you extract; never the question, never a user or conversation id. `session_ref` is opaque. - **Never confirm what did not happen.** A render is confirmed because the item was shown, a click because the person clicked. Confirmation and click rates are monitored, per AI company, against the platform's; an outlier's click revenue is held for review before any payout. - **Never show a paid item without its label**, or alter its creative, its display domain or its destination, or put your own redirect in front of it. - **Never ask for pools you will not use.** A pool holds the advertiser's budget for ten minutes. - **Never present another key's tokens or receipts.** They are bound to the company, and a search receipt to the key, they were issued to. ## Errors Refusals are RFC 9457 problems with a stable `code` ([errors](/reference/errors/)). The ones particular to paid items: | Code | Status | | |---|---|---| | `token_invalid` | `400` | A render token, click token or search receipt MasterDB did not sign, or one issued to another company or key. | | `request_invalid` | `400` | A pool request outside its limits, a format the item does not offer, a row the receipt does not show as sponsored, or a render sent with both a render token and a search receipt. | | `rate_limited` | `429` | Over the key's request rate, or far more pools than renders. Wait as the answer says (`Retry-After`, `retry_after_ms`). | | `unavailable` | `503` | The confirmation cannot be recorded right now. Retry with the same `Idempotency-Key`. | A late, unfunded or repeated confirmation is not an error: it is a `200` with `accepted: false` and its `reason`, so you can tell it from a fault. --- # MCP servers > masterdb-mcp, the self-hosted MCP server an AI company runs with its own key — install, configuration, keys, tools, results, retries and verification — and the hosted public server; and why MasterDB never hosts a billable one. Source: https://docs.masterdb.ai/ai-companies/mcp/ The [Model Context Protocol](https://modelcontextprotocol.io) is how a model in the loop — a chat assistant, a copilot, an internal tool, an autonomous AI — calls tools. MasterDB offers it on two surfaces for AI companies: | Surface | Where | Key | Billed | |---|---|---|---| | **`masterdb-mcp`, self-hosted** — production | runs in your own network, beside your model | your retrieval key, in your process | yes, exactly as the raw API | | **Hosted public** | `https://mcp.masterdb.ai/v1/mcp/public` | none | nothing billable | Businesses have their own hosted server; see [MCP for businesses](/businesses/mcp/). ## Why MasterDB never hosts a billable MCP server MCP's own authentication is an OAuth bearer token. A MasterDB-hosted server for billable calls would bring bearer tokens back into production, remove the signed request that is the other half of every receipt, and put MasterDB in the middle of every call your AI makes. So in production **your key never leaves your company**: you run `masterdb-mcp`, and every tool call becomes an RFC 9421-signed request from your process to the retrieval API. Receipts, billing, blocking, terms, rate limits and non-repudiation are byte for byte those of the raw API, and there is no MasterDB hop between your model and MasterDB. Use MCP when a model decides what to ask. When your own code composes the queries — a retrieval layer, query templates, a search box — use the SDK. Both are the same signed request underneath, and you can use both. ## Install and run Apache 2.0, Node 24 or later, built on the official MCP SDK; the client and server negotiate the protocol version. ```sh npm install -g @masterdb/mcp export MASTERDB_RETRIEVAL_KEY="$(cat retrieval-key.jwk)" # or name a key file in the configuration export MASTERDB_COUNTRY=US # your users' country, when a search names none masterdb-mcp --check # validates the configuration and prints the key id it signs with masterdb-mcp # stdio: what an MCP client starts ``` Try it against the sandbox first: take a sandbox key in the AI Portal ([The sandbox](/sandbox/#taking-a-sandbox-key-in-the-ai-portal)) and set `"environment": "sandbox"`. A sandbox key is never accepted by production, and a production key never by the sandbox. Set the grant the portal answered with the key as `sandbox_key_grant` (or `MASTERDB_SANDBOX_KEY_GRANT`): `masterdb-mcp` sends it as the `MDB-Sandbox-Key` header on every request, and the sandbox admits your key on the first one. Without it that first request is refused `401 key_unknown`. ### Connecting a client **Claude Desktop** (`claude_desktop_config.json`) and **Cursor** (`.cursor/mcp.json`) start the server as a process: ```json { "mcpServers": { "masterdb": { "command": "masterdb-mcp", "args": ["--config", "/etc/masterdb/mcp.json"] } } } ``` **Claude Code**: ```sh claude mcp add masterdb -- masterdb-mcp --config /etc/masterdb/mcp.json ``` ## Configuration A JSON file named by `--config` or `MASTERDB_MCP_CONFIG`, validated against the published schema (`masterdb-mcp --print-config-schema`). Every member can be overridden from the environment. **The private key is never in the file** — the file says where it is. ```json { "version": 1, "environment": "production", "key": { "file": "/run/secrets/masterdb-retrieval-key.jwk" }, "country": "US", "anchors_file": "/etc/masterdb/anchors.json", "verify_online": false, "timeout_ms": 10000, "retry": { "max_attempts": 3, "max_delay_ms": 5000 }, "transport": { "type": "stdio" } } ``` | Member | Environment variable | Default | |---|---|---| | `environment` | `MASTERDB_ENVIRONMENT` | `production` (or `sandbox`) | | `api_base_url` | `MASTERDB_API_URL` | `https://api.masterdb.ai` / `https://sandbox.api.masterdb.ai` | | `public_base_url` | `MASTERDB_PUBLIC_URL` | `https://verify.masterdb.ai` / `https://sandbox.api.masterdb.ai` | | `source_line_base` | `MASTERDB_SOURCE_LINE_BASE` | `https://verify.masterdb.ai` / `https://sandbox.verify.masterdb.ai` (the verify host a source line points to) | | `key.file` or `key.env` | `MASTERDB_KEY_FILE` | `key.env` = `MASTERDB_RETRIEVAL_KEY` | | `country` | `MASTERDB_COUNTRY` | none: a search must then name its country | | `sandbox_key_grant` | `MASTERDB_SANDBOX_KEY_GRANT` | none; sandbox only: the grant (`mdb_sbxk1.…`) the AI Portal answered with the sandbox key, sent as `MDB-Sandbox-Key` on every request; refused with `production` | | `anchors_file` | `MASTERDB_ANCHORS_FILE` | none: `verify` then claims nothing | | `verify_online` | `MASTERDB_VERIFY_ONLINE` | `false` | | `transport.type` | `MASTERDB_MCP_TRANSPORT` | `stdio` | | `transport.host`, `.port`, `.path` | `MASTERDB_MCP_HOST`, `_PORT`, `_PATH` | `127.0.0.1`, `8787`, `/mcp` | | `transport.bearer_token_env` | `MASTERDB_MCP_BEARER_TOKEN_ENV` | required when the host is not loopback | **The key** is the private half of a retrieval key registered in the AI Portal (a sandbox key taken there, for the sandbox) — Ed25519 (the default) or P-256 — as a private JWK or a PKCS #8 PEM, from a file or an environment variable. It is read once at start and held only inside the signer: never logged, never returned by a tool, never sent. An error names where the key was looked for, never what was there. **Streamable HTTP** (`"transport": {"type": "http"}`) is for a fleet where the model runs apart from the process holding the key; one MCP session is one conversation. The server signs with your key for anyone who can reach it, so it listens on loopback by default, requires its own bearer token on any other address, and refuses browser origins you have not allowed. ## Tools, resources and prompts | Tool | Calls | Does | |---|---|---| | `search_products`, `search_business_files`, `search_events`, `search_jobs`, `search_updates` | `POST /v1/{collection}/search` | One search tool per collection, each typed with exactly its fields, operators and sort keys — generated from the same allow-lists as the API ([Fields, filters and sort keys](/reference/search-fields/)). One country, a mandatory sort, at most 50 rows. | | `fetch` | `GET /v1/records/{id}` | One record exactly as sealed, with its seal, sidecar, sealed AI policy, provenance and receipt. | | `verify` | the verifier library; `POST /v1/verify` | Checks what came back: row signatures, the receipt, the sidecar, and — online — the business's seal and its AI policy's seal, with MasterDB's signed statement. | | `receipts` | `GET /v1/receipts` | Your own receipts, by window, key or legal entity; or one checked. | | `ai_policy` | — (local) | What a business permits and the contexts it blocks, from a row's `ai_policy_bits` or a fetched record's sealed AI policy, in plain words. | | `terms` | `GET /v1/terms` | The AI-company Terms, current or by version. | | `ai_policy_key` | `GET /v1/ai-policy-key` | The key to the AI policy bits (public server). | | `usage` | `GET /v1/usage` | Your usage. | | `ads_pool` | `POST /v1/ads/pool` | Up to 20 ads for the user's country and a few subject keywords, in the order the model states; each with a render token. | | `report` | `POST /v1/ads/render`, `/v1/ads/click` | Your signed statement back to MasterDB: `ad_render` (an ad's render token, or a sponsored search row by its search's `retrieval_id` and the `record_id`, with the format) answers a click token and the label to show; `ad_click` confirms a click with that token. | Resources: `masterdb://terms/{version}`, `masterdb://certificate/{business_uuid}`, `masterdb://projection/{type}/{version}`, `masterdb://vocabularies/{name}`, `masterdb://spec`. Prompts: `answer_with_source_line` and `render_sponsored_item`, the source-line and render conventions. There is deliberately **no catalogue, list or bulk resource**: the anti-enumeration rules hold on every transport. **Sponsored items.** Call `ads_pool`, show what you choose with its label, then `report` `ad_render` for each one shown; when the user clicks, `report` `ad_click` with the click token (within 24 hours). A sponsored row in a search result is confirmed the same way, with the search's `retrieval_id`. MasterDB is never in the redirect path: send the person to the destination yourself. [Ads and sponsored items](/ai-companies/ads/) has the rules: labels, windows, repeats and what never to do. ## What the model sees - **Rows and records exactly as served.** A tool result's first block is MasterDB's response body, byte for byte: nothing is parsed and re-serialised, so a record's bytes still hash to its seal. The second block, labelled `masterdb_mcp_notes`, is the server's reading: the terms in plain words, the source line, the receipt's `retrieval_id`, whether the call was billable (never in the sandbox, which bills nothing). - **The fix in every refusal.** A search is validated in your process with the retrieval service's own compiler before anything is sent. A bad one is answered with the API's RFC 9457 problem and the fix — `sort_required` with the valid sort keys, `unknown_field` with the nearest valid name — so the model corrects itself in one turn, with no request sent and nothing billed. - **Two things the model does not guess.** The user's **country** comes from the conversation — your framework sets `masterdb.ai/country` in the tool call's `_meta` — or from the configuration; a country the model names itself is an explicit override. The **`session_ref`** is generated per conversation, or taken from `_meta` `masterdb.ai/session_ref`, and is never shown to the model. - **Data, never instructions.** Text in a record is the business's statement. The server never acts on it and never alters it: a record that contains an instruction aimed at an AI comes back verbatim, as data, like every other string. MasterDB guarantees that text cannot reach beyond the business that published it; defending your model against what it reads is yours ([security model](/threat-model/)). ## Requests, nonces and retries Every request is signed as [Signing requests](/ai-companies/signing-requests/) describes, with a fresh single-use nonce; the server keeps every nonce it signed with until the signature expires, so none is reused. It retries only what MasterDB did not serve: `429` and `503` after `Retry-After` (up to `max_delay_ms`), `502` and `504`, and a connection that failed before the request was sent — each attempt signed afresh. **A request that timed out after it was sent is never retried: it may have been served and billed.** ## Verifying `verify` trusts MasterDB's key set only when it chains to the root anchors in `anchors_file` (the sandbox's anchors for the sandbox); without anchors it says plainly that nothing was verified. The key set is refreshed every ten minutes, so MasterDB's key rotations arrive with no change on your side. The business's key register is MasterDB's, so the seal check itself is online (`verify_online`, or `online: true` on the call), and MasterDB's answer is a statement signed by its statement key, which the server checks. ## The hosted public server It speaks MCP's Streamable HTTP transport at one endpoint. The `initialize` answer carries an `Mcp-Session-Id`: send it on every later request of the conversation. A session is bound to the credential that opened it and ends after 30 minutes idle. There is no server-initiated stream (`GET` answers `405`); `DELETE` with the session id ends a session. The client must accept both `application/json` and `text/event-stream`. The endpoints are in the [reference](/reference/mcp/). **Public — `POST https://mcp.masterdb.ai/v1/mcp/public`.** The public verification reads only: `verify`, `certificate`, `keys`, `projection`, `spec`, `log_checkpoint`, `log_proof` and `vocabularies`. No credential, nothing billable, and no search or fetch. Rate-limited per address generously, so as never to block a checker. --- # Verifier libraries > The open-source libraries (TypeScript and Python, Apache 2.0) that check everything MasterDB signs — seals, rows, receipts, certificates, the key chain, batch proofs and mandates — without an account. Source: https://docs.masterdb.ai/ai-companies/verifier-libraries/ Two libraries, one set of checks, held to one set of test vectors: | | Package | Runtime | Dependencies | |---|---|---|---| | TypeScript | `@masterdb/verifier` | Node 24 or later | none (`node:crypto`) | | Python | `masterdb-verifier` | Python 3.10 or later | `cryptography` | The only network call either makes is fetching the key set (`GET /.well-known/keys`); with a supplied key set everything is offline. Both carry both signature algorithms — the classical one and ML-DSA-65 — and require both for MasterDB's own long-lived artefacts. ## What each check does | Artefact | TypeScript / Python | Checks | |---|---|---| | Key set | `KeySet.fetch`, `KeySet.fromSignedKeySet` / `KeySet.fetch`, `KeySet.from_signed_key_set` | every key certificate chains to a pinned anchor (roots certify roots and issuance keys; issuance keys certify working keys); the set is signed by an issuance key valid at `issued_at`; `compromised_from` marks are applied | | Certificate | `verifyCertificate` / `verify_certificate` | signed by an issuance key valid at `issued_at`; `sandbox` only under the sandbox anchors; returns the `cert_id`, the `subject`, the `status` and since when and, for a `withdrawn` certificate, its `status_reason` | | Key events | `keyAddedLeaf`, `checkKeyAdded` / `key_added_leaf`, `check_key_added`; `keyEvents` and `keyEventProofs` on `verifySeal` (`key_events`, `key_event_proofs` on `verify_seal`) | that the seal's key was added to the business's register in public: every business key is a `key_event` leaf in the transparency log over `{v: 1, kind: "key_added", business_uuid, key_id, valid_from}`, recomputed from the certificate (or the published key list) and the key's `valid_from`; pass the leaf's proof from `GET /v1/log/proof?leaf=`. `warn` (the default) keeps the seal and reports `checks.key_event: "missing"`; `require` refuses it (`key_event_missing`). The check proves the leaf is in the log; it never compares a leaf's position with a seal's | | Key custody | `verifyKeyCustody`, `verifyKeyCustodyStatements`, `keyCustodyInForce` / `verify_key_custody`, `verify_key_custody_statements`, `key_custody_in_force` | who holds each business key, `hosted` (MasterDB, for the business) or `self` ([Key custody](/reference/key-custody/)): `key-custody.v1`, exact members, signed by both halves of an issuance key valid at `issued_at`; the statement in force for a key at an instant | | Seal | `verifySeal` / `verify_seal` | the signature by a key of the business's register (a passkey's WebAuthn assertion — ES256, or RS256 from Windows Hello — Ed25519, or ES256); the hash over the exact bytes, or a Path A inclusion proof against the sealed root; the record's own `schema` known and matching; the key valid at `sealed_at`; with the context supplied, the certificate, AI policy version and scope in force at `sealed_at`; seal format 2 (`seal.v2`, `ai_policy_version`) and format 1 (`seal.v1`, `terms_version`) both verify (a certificate MasterDB withdrew before `sealed_at` is refused as `certificate_withdrawn`, not as a compromise) | | Row | `verifyServedRow` / `verify_served_row` | the projection version from `adl_proj` (unknown: refused); the serve-time members removed; RFC 8785; Ed25519 by a projection key | | Projection re-run | `verifyProjection` / `verify_projection` | the row as above; its `adl_origin` is the fetched record's hash; the record's signed sidecar; then the published projection (spec v1, v2 or v3) re-run over the record and the sidecar and compared with the row byte for byte — nothing added, dropped or changed between the record and the index (`projection_mismatch`). The Python library is an independent port of the projection, held to the same vectors | | Ad pool item | `verifyAdItem`, `verifyAdImage` / `verify_ad_item`, `verify_ad_image` | the item's signed `row` (an `ads` row); `adl_origin` is the hash of `record_base64`; the seal inside the row verifies over those bytes (with the business's register or published keys); every text and link in `creative`, and the row's own, is the sealed one (`ad_mismatch`); the business's AI policy on the item, its seal and the row's `ai_policy_bits`; an image against `adl_creative` | | Receipt | `verifyReceipt` / `verify_receipt` | formats 3, 2 and 1; signed by a receipt key of that region valid and not compromised at `served_at`; each served row matches its entry | | AI policy bits | `verifyAiPolicyBits` / `verify_ai_policy_bits` | a row's `ai_policy_bits` against the exact bytes of the sealed AI policy record a fetch returns (`ai_policy.record`): every mask derived from the named booleans, the version the fetch names, the `purchase` bit alone allowed to be withheld; returns the blocked contexts by code | | Mandate | `verifyMandate`, `mandateCovers` / `verify_mandate`, `mandate_covers` | sealed by a business passkey valid at `valid_from`; covers a key, type, countries and instant | | Merkle | `verifyInclusion` / `verify_inclusion` | RFC 9162 inclusion | | Log | `verifyCheckpoint`, `verifyLogInclusion`, `verifyLogConsistency` / `verify_checkpoint`, `verify_log_inclusion`, `verify_log_consistency` | the log's signed checkpoints and proofs | | A fetch response | `recordBytes` / `record_bytes` | not a check: the exact bytes of the `record` member as served, which is what a seal covers | Every refusal is a `VerificationError` with a stable `reason`, the same in both languages: `envelope_malformed`, `payload_type_mismatch`, `payload_malformed`, `version_unknown`, `key_unknown`, `signature_invalid`, `key_mismatch`, `hash_mismatch`, `record_malformed`, `record_type_mismatch`, `key_not_valid`, `key_compromised`, `certificate_unknown`, `certificate_invalid`, `certificate_not_in_force`, `certificate_withdrawn`, `ai_policy_version_mismatch`, `ai_policy_bits_mismatch`, `out_of_scope`, `mandate_invalid`, `inclusion_invalid`, `trust_chain_broken`, `post_quantum_required`, `projection_unknown`, `row_signature_invalid`, `receipt_row_mismatch`, `projection_mismatch`, `ad_mismatch`, `key_event_missing`. Both libraries are Apache-2.0; each package carries the `LICENSE`. **Unknown versions are refused, loudly.** Every artefact carries its own version (`v`, `schema`, `ai_policy_schema`, `payloadType`); the libraries refuse one they do not know rather than guess, so MasterDB can evolve every format without a flag day and a verifier written today never silently accepts something it does not understand. ## Trust anchors `PRODUCTION_ANCHORS` and `SANDBOX_ANCHORS` are pinned in the libraries, from MasterDB's root key ceremonies; you can also pass anchors explicitly. Anchors are a *set*, so a root rotation is a published successor plus a library update, never a flag day. ## Post-quantum policy Long-lived artefacts — certificates, key custody statements, key certificates, the key set — carry an Ed25519 or P-256 signature and an ML-DSA-65 signature. The libraries require both signatures, each valid and by a key that chains to the pinned anchors; a certificate or key set carrying only one is refused with `post_quantum_required`, and there is no option to relax it. Rows and receipts are Ed25519 only: they are verified within days, and can be re-signed by re-projection if ever needed. ## Test vectors The good cases and the broken-record corpus both libraries are held to are described in [Test vectors](/reference/test-vectors/). --- # The masterdb command line > Verify a record, a fetched response or a receipt from a terminal, fetch and check MasterDB's key set, and make and use test keys. Source: https://docs.masterdb.ai/ai-companies/cli/ `masterdb` checks what MasterDB signs, from a terminal, on the TypeScript [verifier](/ai-companies/verifier-libraries/). Node 24 or later. ```sh npm install --global @masterdb/cli ``` | Command | Does | |---|---| | `masterdb verify ` | Verifies a fetch response (the record's bytes taken verbatim from its `record` member) or a verify request (`{record_base64, seal, sidecar?}`). With `--context FILE` (the business's key register, certificates, AI policy history and mandates) it verifies offline; otherwise it asks `POST /v1/verify` and, given anchors, checks MasterDB's signed statement. A receipt in the response is verified too. | | `masterdb verify --url URL` | Fetches the record first — signed with RFC 9421 (`tag="mdb-retrieval"`) when `--sign-key` names a test key — then as above. | | `masterdb receipt ` | Shows a receipt (or the one in a response) and, given anchors, verifies it and the served rows beside it. | | `masterdb jwks` | Fetches `/.well-known/keys` and, given anchors, verifies the whole chain; `--out` saves it for offline use with `--keys`. | | `masterdb keygen` | Makes a **test** key (`--alg ed25519` or `es256`), marked `x-masterdb-test`. | | `masterdb sign ` | Seals a record's exact bytes with a test key (`--cert-id`, `--ai-policy-version`, `--record-type`, `--sealed-at`) and prints the seal envelope. It refuses any key without the test mark: a production key belongs in the business's own signing system. | Trust comes only from anchors: `--anchors FILE` (a JSON array of root public keys), or the pinned set. **Without anchors the tool says plainly that nothing was verified.** `--sandbox` points at the sandbox and its anchors; `--api` at any deployment; `--json` prints machine-readable results. Exit status: `0` verified, `1` a check failed, `2` a usage error — so it drops into a script or a CI step. --- # Rate limits and errors > The two layers of rate limiting with their figures, the per-key cost budget, and how every refusal is reported — RFC 9457 problems with a stable code to branch on. Source: https://docs.masterdb.ai/ai-companies/rate-limits-and-errors/ ## Errors Every refusal on every API is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem document, `Content-Type: application/problem+json`: ```json { "type": "https://docs.masterdb.ai/errors/sort_required", "title": "A sort is required", "status": 400, "code": "sort_required", "detail": "…", "instance": "/v1/search", "errors": [ { "code": "…", "pointer": "/filter/all/1", "detail": "…" } ] } ``` **Branch on `code`.** Codes are permanent: once published, a code is never renamed or reused for another meaning. `title` and `detail` are human text and may change. When a request or a record fails several checks, `errors[]` lists every reason at once, each with a JSON Pointer into what you sent. [Error codes](/reference/errors/) lists every code; each problem's `type` links to its row there. ```typescript title="samples/typescript/errors.ts" // Errors: every refusal is an RFC 9457 problem with a stable `code`. Branch on the code, never on the text. import assert from 'node:assert/strict'; import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { SANDBOX, createEd25519Signer, createRetrievalClient } from '@masterdb/client'; const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_KEY_FILE as string))); const client = createRetrievalClient({ baseUrl: process.env.MASTERDB_API_URL ?? SANDBOX.retrieval, signer }); // No sort: refused with `sort_required`, never answered in an order MasterDB chose. Refusals are never billed. const noSort = await client.POST('/v1/search', { body: { collection: 'products', filter: { all: [{ country: 'US' }] } } as never }); assert.equal(noSort.response.status, 400); assert.equal(noSort.error?.code, 'sort_required'); console.log(noSort.error?.code, '-', noSort.error?.detail); // No country, or two: `filter_required`. A field outside the collection's allow-list: `unknown_field`. const noCountry = await client.POST('/v1/search', { body: { collection: 'products', sort_by: 'published_at:desc' } as never }); assert.equal(noCountry.error?.code, 'filter_required'); const unknown = await client.POST('/v1/search', { body: { collection: 'products', filter: { all: [{ country: 'US' }, { colour: 'red' }] }, sort_by: 'published_at:desc' } as never, }); assert.equal(unknown.error?.code, 'unknown_field'); console.log(noCountry.error?.code, unknown.error?.code); /** What to do with a refusal: fix the request, wait, or stop. */ function nextStep(status: number, code: string, retryAfter: string | null): string { if (status === 429 || code === 'unavailable') return `retry after ${retryAfter ?? '1'} s`; if (code === 'allowance_exhausted') return 'stop: the prepaid allowance is spent'; if (status === 401) return 'check the key and the signature (clock, nonce, digest)'; return 'fix the request'; } console.log(nextStep(noSort.response.status, noSort.error?.code ?? '', noSort.response.headers.get('retry-after'))); ``` ```python title="samples/python/errors.py" """Errors: every refusal is an RFC 9457 problem with a stable `code`. Branch on the code, never on the text.""" import os import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, load_key client = httpx.Client( base_url=os.environ.get("MASTERDB_API_URL", SANDBOX_API), auth=MasterDBAuth(load_key(os.environ["MASTERDB_KEY_FILE"])), ) # No sort: refused with `sort_required`, never answered in an order MasterDB chose. Refusals are never billed. no_sort = client.post("/v1/search", json={"collection": "products", "filter": {"all": [{"country": "US"}]}}) problem = no_sort.json() assert no_sort.status_code == 400 and problem["code"] == "sort_required" assert no_sort.headers["content-type"].startswith("application/problem+json") print(problem["code"], "-", problem.get("detail")) # No country, or two: `filter_required`. A field outside the collection's allow-list: `unknown_field`. no_country = client.post("/v1/search", json={"collection": "products", "sort_by": "published_at:desc"}) assert no_country.json()["code"] == "filter_required" unknown = client.post( "/v1/search", json={"collection": "products", "filter": {"all": [{"country": "US"}, {"colour": "red"}]}, "sort_by": "published_at:desc"}, ) assert unknown.json()["code"] == "unknown_field" print(no_country.json()["code"], unknown.json()["code"]) def next_step(res: httpx.Response) -> str: """What to do with a refusal: fix the request, wait, or stop.""" code = res.json().get("code", "") if res.status_code == 429 or code == "unavailable": return f"retry after {res.headers.get('retry-after', '1')} s" if code == "allowance_exhausted": return "stop: the prepaid allowance is spent" if res.status_code == 401: return "check the key and the signature (clock, nonce, digest)" return "fix the request" print(next_step(no_sort)) ``` | Status | What to do | |---|---| | `400`, `422` | Fix the request. Never retry unchanged: the answer will not change. | | `401` | Check the key and the signature: a registered and unrevoked key, a synchronised clock, a fresh nonce, a `Content-Digest` over the exact bytes sent ([Signing requests](/ai-companies/signing-requests/)). | | `402` `allowance_exhausted` | Stop: your prepaid allowance is spent ([Usage and billing](/ai-companies/usage-and-billing/)). | | `404` | The record is not available to you. A blocked, withdrawn and never-existing record are the same answer; do not retry. | | `429` `rate_limited` | Wait the `Retry-After` seconds, sign again, retry. | | `503` `unavailable` | Wait the `Retry-After` seconds and retry. Not billed. | | `500` `internal` | MasterDB failed. Retry with backoff; not billed. | A retry is a new request: sign it again, with a new nonce. ## Rate limits Two layers, both stated. The figures are the same for every AI company and every key, in the sandbox and in production: there is one set of limits, and no company has its own. - **At the edge**, per client IP address: 6,000 requests a minute (100 a second) across the public hosts (`api`, `verify` and `mcp`), behind a web application firewall. The edge limit sits above one key's limit below, so it never refuses what a single key may send. - **Per key** (not per company), by the retrieval service, on every retrieval request: | | Limit | |---|---| | Request rate | 50 requests a second | | Burst | 100 requests | | Query cost budget | 6,000 cost units a minute | The ads endpoints have their own per-key limit of 50 a second with a burst of 100. A request over either limit is `429` `rate_limited` with `Retry-After`, in whole seconds and at least 1. The **cost budget** is measured in the index time your searches take: a search costs the milliseconds the index spent on it, rounded up, and at least one unit, so a query that scans everything costs its sender more of the budget than a narrow one. The budget refills continuously. It is checked before a search and charged after it, so a single expensive search can take the balance below zero, and the next searches wait until it is positive again. Plan on the figures above for one key, and back off when you are told to. Neither layer is the billing meter; the receipts are. ## Idempotency Every `POST` that creates something takes an `Idempotency-Key` header (a fresh UUID each time); a retry with the same key answers what the first attempt answered. Searches and fetches create nothing and need none. --- # Publishing on MasterDB > How a business, or the integrator working for it, gets its products, Business & Brand file, events, jobs and updates to AI companies — sealed, or not published. Source: https://docs.masterdb.ai/businesses/overview/ A business publishes five types of record — **products**, its **Business & Brand file**, **events**, **jobs** and **updates** — plus its **AI policy**: what it permits AIs to do with its data, and the contexts it does not want it used in. AI companies retrieve them through signed requests, and receive them exactly as the business sealed them. ## One rule: sealed, or not published Every record is signed over its exact bytes before MasterDB stores it. A business that holds its own keys seals with them, and MasterDB never holds them; a business that uses hosted signing has MasterDB sign each publish for it only after a person at the business confirms it, and MasterDB's signed [key custody statement](/reference/key-custody/) says which. A seal that does not verify is refused, with a reason that names the seal — distinct from a validation failure — and the refusal is recorded where you can see it. ## Two ways in | | Path B — a person in the Business Portal | Path A — your own system | |---|---|---| | Who | anyone at the business with a publishing role | your catalogue system, a connector, an agency's feed | | Seals with | their passkey, at the moment they save | your integration key, under a mandate a person sealed with their passkey | | What is sealed | a canonical form of the draft the browser builds | the records' exact bytes, as your system sent them | | Per | save | batch of up to 10,000 records, or a bulk file of up to 100,000 | | Meaning | a save publishes the draft | a push **replaces** each record it names | | Imports | a CSV **merges** into drafts; the person reviews and seals the result | — | Read: [Publishing through the portal](/businesses/portal-publishing/), [Sealing with a passkey](/businesses/passkey-sealing/), [Pushes and mandates](/businesses/pushes/). ## What happens when you publish 1. **Checks.** The bytes must be one strict JSON value with its `schema`; every reason is returned at once. Plain text only, role addresses only, money as decimal strings, a price only for a country the record is published in, safe URLs, and the **scope rule**: a record may speak for your business and the brands it owns or sells, and may not make claims about another company or direct how other sources are treated. 2. **The seal** is verified: the key was valid at `sealed_at`, the certificate and AI policy version named were in force, and the person or key was allowed to publish this type. 3. **Stored**, byte for byte, with MasterDB's signed sidecar (what it checked, the resolved scope, a geocoded coordinate where there is an address), and recorded in the transparency log. 4. **Projected** into signed index rows and **fanned out** to every region; the record is live everywhere within seconds. 5. **A confirmation**: for a save in the portal, an email to your owners and admins naming who published, with a plain link to the portal (each chooses how often: [Choosing which emails you get](/businesses/emails/)); for a push, the response names each record's id, version and hash. ## Your identity MasterDB verifies your business before you publish, and issues a **certificate**: your legal name, your country, that MasterDB verified you and since when, and your status, signed by MasterDB and public. It does not say how you were verified. Verification raises the cost of impersonation; it does not make it impossible, and it is not an endorsement of what you publish. See [What an AI company sees](/businesses/what-ai-companies-see/). ## Testing Everything above works in the [sandbox](/sandbox/), where a business publishes without verification and nothing is served to a production AI company. --- # Publishing through the portal > Drafts, validation, the diff, and the save that seals — how a person publishes from the Business Portal. Source: https://docs.masterdb.ai/businesses/portal-publishing/ In the Business Portal every record starts as a **draft**. A draft is private, editable by anyone at your business with the right role, and never served. Publishing is a **save**: the person reviews what will be published and seals it with their passkey. ## Drafts - Every record type has drafts: products, the Business & Brand file, events, jobs, updates, and your AI policy. - **Validation** runs as you edit and again on save, with every reason at once: the same rules a push meets. - **The diff since the last publish** is on the save screen, so what you seal is what you have looked at — including anything a colleague changed. - On a verified business, editing a draft needs a recently confirmed sign-in, so a stolen email session cannot plant a change for someone else to seal unseen. ## The save On save, your browser builds the draft's **canonical form** — UTF-8, keys sorted, no extra whitespace, decimal quantities (money included) as strings — hashes it, and your passkey signs it ([Sealing with a passkey](/businesses/passkey-sealing/)). MasterDB checks that the bytes it received are exactly that canonical form and hash to what you sealed, stores exactly those bytes, and refreshes the draft from them. A colleague's edit made between your review and your save can never be what gets sealed. Who may save what depends on your role: a catalogue manager's seal is accepted for products and refused for the Business & Brand file. The role in force at the moment of sealing is recorded with the record. ## After a save - The record is live in every region within seconds. The portal shows where it is live. - Every owner and admin receives a **confirmation** by email that names the person who published, with a plain link to the record in the portal — never a sign-in link. Each owner and admin chooses how: one person's changes grouped into one email every 10 minutes (the default), an email each time, a daily summary, or off ([Choosing which emails you get](/businesses/emails/)). An unexpected confirmation is how a compromised account or a compromised portal is noticed within minutes. - A later save publishes a new version of the same record; the history stays. ## Withdrawing and deleting Withdrawing takes a record out of serving everywhere; its history stays. Deleting removes it: its bytes are purged within 30 days, and MasterDB keeps only its fingerprints — its hashes and log entries — so it can still say *when* a record with that hash was live, but not *what* it said. Both are sealed actions, like a save. ## Imports and images - A CSV import **merges** into your drafts; you review and seal the result. See [Imports](/businesses/imports/). - Images are uploaded, checked and re-encoded before they are stored. See [Images](/businesses/images/). --- # Sealing with a passkey > What a passkey seal is, what it proves, how keys are enrolled, revoked and recovered, and why it cannot be phished. Source: https://docs.masterdb.ai/businesses/passkey-sealing/ When a person at your business saves a record, or seals a mandate for your system, their **passkey** signs it. A passkey is a key pair created on their device by the device's own authenticator; the private half never leaves it, and it is bound to MasterDB's domain, so a look-alike site cannot use it. MasterDB holds only the public half. ## What is signed A seal is a DSSE envelope over a small payload: ```json { "v": 2, "key_id": "…", "cert_id": "sha256:…", "hash": "sha256:…", "sealed_at": "2026-10-01T09:30:00.000Z", "record_type": "products", "ai_policy_version": 3 } ``` `hash` is over the exact bytes stored; `cert_id` names your business's certificate in force; `ai_policy_version` your AI policy in force (an AI policy record's own seal names the version it creates). The envelope's `payloadType` is `application/vnd.masterdb.seal.v2+json`; format 1 seals (`seal.v1`, the same payload with `terms_version`) verify for ever. The passkey's WebAuthn assertion is made over `"mdb-seal"` followed by the SHA-256 of the envelope's pre-authentication encoding, so every member of the payload is under the person's signature. The envelope carries the assertion's `authenticatorData` and `clientDataJSON`, so anyone can check it with any WebAuthn library: the relying party is `masterdb.ai`, the origin one of MasterDB's portals, and the person was present and verified. A seal proves **who** published **these bytes** and **when**. It does not prove they are true. ## Enrolling A person enrols their passkey once, during your business's verification, and is asked to add a **second passkey on another device** at the same time: it is the first way back if a device is lost. A person who skips it is reminded; the Users screen shows a business whose publishing depends on one passkey. A synced passkey (one your platform backs up to your other devices) is as safe as the account that syncs it, and the credential records which kind it is. A business can require device-bound passkeys for sealing. ## Validity is judged when you seal MasterDB checks, at `sealed_at`, that the key existed and was not revoked, that the person held a role allowing this record type, and that your business was verified and not suspended — and records those facts with the record. A key revoked next year changes nothing about a seal made today. Two different events, two different fields: - **A person leaves**: their key gets an end date. Everything they sealed before stays valid. - **A key is compromised**: it is revoked from an *effective* moment, which may be in the past. Seals made after that moment are invalid; seals before it stand. ## Losing a passkey 1. **A second passkey** on another device: sign in with it and carry on. 2. **Another owner or admin** ends the lost key and re-invites the person, who enrols afresh. 3. **A sole person with a single lost device** signs in by email link — a session that can do nothing sensitive — and goes through MasterDB's account recovery. A **72-hour cooling period** follows, in which the owners and admins are emailed and the new passkey can sign in but not seal. Published records keep serving throughout; only new publishing waits. ## Mandates are sealed the same way A **mandate** — the document that lets your system publish with an integration key — is sealed by an owner or admin with their passkey, exactly as a record is. The delegation to a machine is itself a person's signed act, scoped and time-limited. See [Pushes and mandates](/businesses/pushes/). --- # Pushes and mandates > Path A — your own system publishes batches sealed with your integration key, under a mandate a person sealed with their passkey. Keys, mandates, the batch seal, outcomes, the price-shock hold, read-back and withdrawal. Source: https://docs.masterdb.ai/businesses/pushes/ A business that publishes from its own systems — its catalogue platform, a connector, an agency's feed — does not send a bearer token. It holds its own **integration key**, signs every request with it, and seals every batch with it. What authorises that key is a **mandate**: a small document a person at the business sealed with their passkey. The mandate says the key *may* publish; the seal says it *did*. Pushes carry `products`; the other record types are published through the Business Portal. ## 1. The integration key Generate an Ed25519 (or P-256) key pair in your own system — a secrets manager or HSM — and have someone with the `developer` role register the public half in the Business Portal. The private half never touches MasterDB. Rotation is overlap: register the new key, move to it, retire the old one. For the sandbox, take a sandbox integration key in the same Business Portal. Its answer carries a `sandbox_key_grant`; send it as the `MDB-Sandbox-Key` header on your sandbox requests, and the first one registers the key in the sandbox, with a sandbox mandate ([The sandbox](/sandbox/)). Without it the first request is refused `key_unknown`. ## 2. The mandate An owner or admin seals a mandate for the key with their passkey: ```json { "v": 1, "mandate_id": "…", "business_uuid": "…", "key_id": "…", "scope": { "record_types": ["products"], "countries": ["US", "GB"], "directions": ["publish"], "caps": { "records_per_hour": 50000, "bytes_per_day": 2000000000 }, "source_ip_allow_list": null }, "valid_from": "…", "valid_until": "…" } ``` - **Scope**: the record types and countries the key may publish. A push outside it is refused. - **Caps**: records per hour and bytes per day. A request over either is answered `429` `rate_limited` with `Retry-After` before its body is even parsed — so a stolen key cannot flood your catalogue. - **Source allow-list** (optional): CIDR ranges and AS numbers the key may push from; anything else is `403` `source_not_allowed`. - **Validity**: at most 92 days. Renewing is one tap a quarter; revoking is immediate. ## 3. A push ```typescript title="samples/typescript/push.ts" // Path A: your system pushes a batch of products, sealed with your own integration key under a mandate. // // MASTERDB_INTEGRATION_KEY_FILE the integration key's private half (PKCS #8 PEM); its public half is registered // in the Business Portal and covered by a mandate you sealed with your passkey // MASTERDB_BUSINESS_UUID your business's public identifier // MASTERDB_AI_POLICY_VERSION your AI policy's version in force (GET /v1/seal-context: ai_policy_version) import assert from 'node:assert/strict'; import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { SANDBOX, createBusinessClient, createEd25519Signer, createPublicClient, idempotencyKey, sealBatch } from '@masterdb/client'; const baseUrl = process.env.MASTERDB_API_URL ?? SANDBOX.business; const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_INTEGRATION_KEY_FILE as string))); const business = createBusinessClient({ baseUrl, signer }); const businessUuid = process.env.MASTERDB_BUSINESS_UUID as string; // The seal names the certificate in force: read its cert_id from the public certificate endpoint. const cert = await createPublicClient({ baseUrl }).GET('/v1/certificates/{uuid}', { params: { path: { uuid: businessUuid } } }); if (cert.error) throw new Error(cert.error.code); if (cert.data.cert_id === undefined) throw new Error('the business has no certificate in force'); // Each record is one strict JSON object with its `schema`; money is a decimal string, never a number. const product = { schema: 'masterdb/products/1', language: 'en', business_product_id: 'EMBERS-00031', product_name: 'Ember Spindle Roof Box 31', countries: ['US', 'GB'], vertical: 'automotive', category: 'parts_accessories', channel: 'online', brand: 'Ember Spindle', tags: ['sandbox', 'automotive'], short_description: 'A fictional roof box from the MasterDB sandbox documentation. It does not exist.', prices: [ { country: 'US', currency: 'USD', amount: '189.00' }, { country: 'GB', currency: 'GBP', amount: '149.00' }, ], on_sale: false, availability: 'available', product_url: 'https://ember-spindle.sandbox.masterdb.ai/products/embers-00031', }; // A second record, wrong on purpose: its price is the number 189, not the string "189.00". It is answered // `rejected` with `money_not_string` while the first record is published: every record gets its own outcome. const broken = { ...product, business_product_id: 'EMBERS-00032', prices: [{ country: 'US', currency: 'USD', amount: 189 }] }; // The bytes you seal are the bytes you send: serialise once, seal those, send those. const records = [product, broken].map((r) => new TextEncoder().encode(JSON.stringify(r))); const body = await sealBatch({ records, signer, certId: cert.data.cert_id, aiPolicyVersion: Number(process.env.MASTERDB_AI_POLICY_VERSION ?? 0), seq: Date.now(), // must rise with every batch this key sends: a replayed older batch is refused recordType: 'products', }); const pushed = await business.POST('/v1/publish/{type}', { params: { path: { type: 'products' }, header: { 'Idempotency-Key': idempotencyKey() } }, body, }); if (pushed.error) throw new Error(`${pushed.error.code}: ${pushed.error.detail ?? ''}`); // Up to 50 records are processed while you wait: 200, with every record's outcome. More (up to 10,000) are // accepted, then processed in the background: 202 with the push's status. Handle both: poll the status until it // settles, then read the per-record outcomes from it, 100 a page (/businesses/large-pushes/). type Outcome = { leaf_index: number; outcome: string; record_id?: string; version?: number; errors?: Array<{ code: string }> }; async function outcomesOf(answer: NonNullable): Promise<{ held: boolean; results: Outcome[] }> { if ('results' in answer) return { held: answer.held, results: answer.results }; const pushId = answer.push_id; let status = answer; while (status.poll_after_seconds !== undefined) { await new Promise((r) => setTimeout(r, status.poll_after_seconds as number * 1000)); const read = await business.GET('/v1/pushes/{push_id}', { params: { path: { push_id: pushId } } }); if (read.error) throw new Error(read.error.code); status = read.data; } if (status.state === 'failed') throw new Error(`the push stopped: ${status.error?.code ?? ''} (send the same batch again to resume it)`); const results: Outcome[] = []; for (let cursor: string | undefined; ; ) { const page = await business.GET('/v1/pushes/{push_id}', { params: { path: { push_id: pushId }, query: cursor === undefined ? {} : { cursor } } }); if (page.error) throw new Error(page.error.code); results.push(...page.data.results); cursor = page.data.next_cursor; if (cursor === undefined) return { held: status.held, results }; } } const outcome = await outcomesOf(pushed.data); for (const r of outcome.results) console.log(r.leaf_index, r.outcome, r.record_id ?? '', JSON.stringify(r.errors ?? [])); const [first, second] = outcome.results; // `accepted` when EMBERS-00031 was not live, `updated` when this push is a new version of it (every run after the // first): both mean it is published. `held` means the price-shock rule stopped the batch for an owner to confirm. if (outcome.held) { console.log('EMBERS-00031 is held: an owner or admin confirms the batch in the Business Portal before it goes live'); } else { assert.ok(first?.outcome === 'accepted' || first?.outcome === 'updated', `EMBERS-00031 answered ${first?.outcome}`); console.log(first.outcome === 'accepted' ? 'EMBERS-00031 is published, new' : `EMBERS-00031 is published, version ${first.version ?? '?'}`); } assert.equal(second?.outcome, 'rejected'); assert.equal(second?.errors?.[0]?.code, 'money_not_string'); // Read back what is published (a manifest, not an export) and the audit of what your pushes tried. const catalogue = await business.GET('/v1/catalogue'); if (catalogue.error) throw new Error(catalogue.error.code); if (!outcome.held) assert.ok(catalogue.data.entries.some((e) => e.business_product_id === 'EMBERS-00031')); const attempts = await business.GET('/v1/push-attempts', { params: { query: { from: new Date(Date.now() - 3_600_000).toISOString() } } }); if (attempts.error) throw new Error(attempts.error.code); console.log(`${catalogue.data.entries.length} records in the catalogue; ${attempts.data.attempts.length} push attempts in the last hour`); ``` ```python title="samples/python/push.py" """Path A: your system pushes a batch of products, sealed with your own integration key under a mandate. MASTERDB_INTEGRATION_KEY_FILE the integration key's private half (PKCS #8 PEM); its public half is registered in the Business Portal and covered by a mandate you sealed with your passkey MASTERDB_BUSINESS_UUID your business's public identifier MASTERDB_AI_POLICY_VERSION your AI policy's version in force (GET /v1/seal-context: ai_policy_version) """ import json import os import time import uuid from datetime import datetime, timedelta, timezone import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, business_tag, load_key, seal_batch base_url = os.environ.get("MASTERDB_API_URL", SANDBOX_API) key = load_key(os.environ["MASTERDB_INTEGRATION_KEY_FILE"]) business = httpx.Client(base_url=base_url, auth=MasterDBAuth(key, tag=business_tag)) business_uuid = os.environ["MASTERDB_BUSINESS_UUID"] # The seal names the certificate in force: read its cert_id from the public certificate endpoint. cert = httpx.get(f"{base_url}/v1/certificates/{business_uuid}") cert.raise_for_status() # Each record is one strict JSON object with its `schema`; money is a decimal string, never a number. product = { "schema": "masterdb/products/1", "language": "en", "business_product_id": "EMBERS-00033", "product_name": "Ember Spindle Roof Rack 33", "countries": ["US", "GB"], "vertical": "automotive", "category": "parts_accessories", "channel": "online", "brand": "Ember Spindle", "tags": ["sandbox", "automotive"], "short_description": "A fictional roof rack from the MasterDB sandbox documentation. It does not exist.", "prices": [{"country": "US", "currency": "USD", "amount": "129.00"}, {"country": "GB", "currency": "GBP", "amount": "99.00"}], "on_sale": False, "availability": "available", "product_url": "https://ember-spindle.sandbox.masterdb.ai/products/embers-00033", } # A second record, wrong on purpose: its price is the number 129, not the string "129.00". It is answered # `rejected` with `money_not_string` while the first record is published: every record gets its own outcome. broken = {**product, "business_product_id": "EMBERS-00034", "prices": [{"country": "US", "currency": "USD", "amount": 129}]} # The bytes you seal are the bytes you send: serialise once, seal those, send those. records = [json.dumps(r, separators=(",", ":"), ensure_ascii=False).encode("utf-8") for r in (product, broken)] body = seal_batch( records, key, cert_id=cert.json()["cert_id"], ai_policy_version=int(os.environ.get("MASTERDB_AI_POLICY_VERSION", "0")), seq=time.time_ns() // 1_000_000, # must rise with every batch this key sends: a replayed older batch is refused ) pushed = business.post("/v1/publish/products", json=body, headers={"Idempotency-Key": str(uuid.uuid4())}) if pushed.status_code not in (200, 202): raise SystemExit(f"{pushed.json()['code']}: {pushed.json().get('detail', '')}") def outcomes_of(answer): """Up to 50 records are processed while you wait: 200, with every record's outcome. More (up to 10,000) are accepted, then processed in the background: 202 with the push's status. Handle both: poll the status until it settles, then read the per-record outcomes from it, 100 a page (/businesses/large-pushes/).""" if answer.status_code == 200: return answer.json()["held"], answer.json()["results"] status = answer.json() push_id = status["push_id"] while "poll_after_seconds" in status: time.sleep(status["poll_after_seconds"]) read = business.get(f"/v1/pushes/{push_id}") read.raise_for_status() status = read.json() if status["state"] == "failed": raise SystemExit(f"the push stopped: {status['error']['code']} (send the same batch again to resume it)") results, cursor = [], None while True: page = business.get(f"/v1/pushes/{push_id}", params={} if cursor is None else {"cursor": cursor}) page.raise_for_status() results.extend(page.json()["results"]) cursor = page.json().get("next_cursor") if cursor is None: return status["held"], results held, results = outcomes_of(pushed) for r in results: print(r["leaf_index"], r["outcome"], r.get("record_id", ""), r.get("errors", [])) first, second = results # `accepted` when EMBERS-00033 was not live, `updated` when this push is a new version of it (every run after the # first): both mean it is published. `held` means the price-shock rule stopped the batch for an owner to confirm. if held: print("EMBERS-00033 is held: an owner or admin confirms the batch in the Business Portal before it goes live") else: assert first["outcome"] in ("accepted", "updated"), f"EMBERS-00033 answered {first['outcome']}" print("EMBERS-00033 is published, new" if first["outcome"] == "accepted" else f"EMBERS-00033 is published, version {first.get('version', '?')}") assert second["outcome"] == "rejected" and second["errors"][0]["code"] == "money_not_string" # Read back what is published (a manifest, not an export) and the audit of what your pushes tried. catalogue = business.get("/v1/catalogue") catalogue.raise_for_status() if not held: assert any(e.get("business_product_id") == "EMBERS-00033" for e in catalogue.json()["entries"]) since = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat(timespec="milliseconds").replace("+00:00", "Z") attempts = business.get("/v1/push-attempts", params={"from": since}) attempts.raise_for_status() print(len(catalogue.json()["entries"]), "records in the catalogue;", len(attempts.json()["attempts"]), "push attempts in the last hour") ``` The body of `POST /v1/publish/products` is `{batch_seal, records}`: each record's exact bytes, base64, and one seal over the batch. - **Serialise once, seal those bytes, send those bytes.** The seal is over the RFC 6962 Merkle root of the records' bytes; each stored record keeps its leaf index and inclusion proof, so it verifies on its own. - **`seq`** must be greater than the last batch this key had accepted. This is what stops a stolen key rolling a price back by replaying an old, validly sealed batch. A counter you keep, or the time in milliseconds, works. - **`sealed_at`** must be within five minutes of when MasterDB receives the batch. - **`cert_id`** names your certificate in force, and **`ai_policy_version`** your AI policy in force: read both from `GET /v1/seal-context` (signed with `mdb-business-read`, like every read). If you seal with a version that is no longer in force, the push is refused `seal_invalid`, and the problem's `ai_policy_version` member names the version in force: read the seal context again and seal again. The batch seal is format 2 (`batch-seal.v2`). - The request is signed with RFC 9421 and `tag="mdb-push"` ([Signing requests](/ai-companies/signing-requests/)), and carries an `Idempotency-Key`: a retried batch with the same `seq` answers what the first attempt answered. **Up to 50 records**, the answer has one outcome per record, in the order sent (a larger push, up to 10,000 records, is answered `202` at once and processed in the background: see [Large pushes and bulk files](/businesses/large-pushes/)): `accepted` (a record that was not live), `updated` (a new version of a record you had published), `rejected`, with every reason at once — `money_not_string` at `/prices/0/amount`, say — or `held` (below). `accepted` and `updated` both mean the record is published: push the same record twice and the second answer is `updated`. One bad record never fails the batch: the sample's second record carries its price as the number `189`, not the string `"189.00"`, on purpose, and is rejected while the first is published. **If a push does not answer** — a timeout, a dropped connection, a `5xx` — nothing about it is a guess. Before any record is stored, every record of the batch has a row in `GET /v1/push-attempts`: `rejected` with its reasons, or `pending`; each `pending` row becomes `accepted`, `updated` or `held` in the same commit that publishes or holds its record. A row still `pending` was not published by that push. To finish it, send the **exact batch again** — the same `batch_seal` and records, signed afresh: the records it already settled keep their outcome, the rest are completed, nothing is published twice, and the answer is the whole batch's. A different batch under the same `seq` is refused `seq_not_increasing`. A large push's progress is on its status, `GET /v1/pushes/{push_id}`. **A push replaces.** Each record in a push replaces the whole record it names; a member you leave out is gone from the new version. (A hand-set image is the exception: an image a person set in the portal survives a feed push that omits the image.) ## The price-shock hold For a catalogue of 20 or more listings, a push that would change more than a fifth of its prices, change any price by more than half, or remove more than a fifth of its listings is **stored but not published**, and held for an owner or admin to confirm in the portal. Your push answers `held: true`, and the records it stored the outcome `held`. This is the check a person makes that a stolen key cannot. ## Reading back - `GET /v1/catalogue` — the manifest of what you have published: ids, your `business_product_id`, `last_updated`, `source`; cursor-paged, rate-limited above 1,000 records. `GET /v1/catalogue/{record_id}` answers one record by either id. It is a manifest, not an export. - `GET /v1/push-attempts?from=&to=&status=` — every record every push tried, with its outcome and reason (`pending` while its push has not settled it). - `GET /v1/analytics/daily` and `/v1/analytics/who-is-asking` — your own figures, by country, and which AI companies are asking. Reads are signed with the same key and `tag="mdb-business-read"`: owning a registered key is enough, because a mandate authorises publishing, not reading. The TypeScript SDK picks the tag by method. ## Withdrawing and deleting A withdrawal or a deletion is a sealed document — `{record_id, action, at}` — never an HTTP verb on a bare id. A `DELETE` without a seal is refused. ```typescript title="samples/typescript/withdraw.ts" // Withdraw a record: a sealed document, never an HTTP verb on a bare id. The record of truth and its // history stay; the record leaves serving in every region. import assert from 'node:assert/strict'; import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { SANDBOX, createBusinessClient, createEd25519Signer, createPublicClient, idempotencyKey, sealAction } from '@masterdb/client'; const baseUrl = process.env.MASTERDB_API_URL ?? SANDBOX.business; const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_INTEGRATION_KEY_FILE as string))); const business = createBusinessClient({ baseUrl, signer }); const businessUuid = process.env.MASTERDB_BUSINESS_UUID as string; const cert = await createPublicClient({ baseUrl }).GET('/v1/certificates/{uuid}', { params: { path: { uuid: businessUuid } } }); if (cert.error) throw new Error(cert.error.code); if (cert.data.cert_id === undefined) throw new Error('the business has no certificate in force'); // Find the record by your own id in the read-back manifest. const catalogue = await business.GET('/v1/catalogue'); if (catalogue.error) throw new Error(catalogue.error.code); const entry = catalogue.data.entries.find((e) => e.business_product_id === 'EMBERS-00031'); assert.ok(entry, 'run the push sample first'); const body = await sealAction({ recordId: entry.record_id, action: 'withdraw', recordType: 'products', signer, certId: cert.data.cert_id, aiPolicyVersion: Number(process.env.MASTERDB_AI_POLICY_VERSION ?? 0), }); const withdrawn = await business.POST('/v1/publish/{type}/withdraw', { params: { path: { type: 'products' }, header: { 'Idempotency-Key': idempotencyKey() } }, body, }); if (withdrawn.error) throw new Error(`${withdrawn.error.code}: ${withdrawn.error.detail ?? ''}`); console.log(withdrawn.data.record_id, withdrawn.data.action); assert.equal(withdrawn.data.action, 'withdraw'); ``` ```python title="samples/python/withdraw.py" """Withdraw a record: a sealed document, never an HTTP verb on a bare id. The record of truth and its history stay; the record leaves serving in every region.""" import os import uuid import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, business_tag, load_key, seal_action base_url = os.environ.get("MASTERDB_API_URL", SANDBOX_API) key = load_key(os.environ["MASTERDB_INTEGRATION_KEY_FILE"]) business = httpx.Client(base_url=base_url, auth=MasterDBAuth(key, tag=business_tag)) cert = httpx.get(f"{base_url}/v1/certificates/{os.environ['MASTERDB_BUSINESS_UUID']}") cert.raise_for_status() # Find the record by your own id in the read-back manifest. catalogue = business.get("/v1/catalogue") catalogue.raise_for_status() entry = next((e for e in catalogue.json()["entries"] if e.get("business_product_id") == "EMBERS-00033"), None) assert entry, "run the push sample first" body = seal_action( entry["record_id"], "withdraw", key, cert_id=cert.json()["cert_id"], ai_policy_version=int(os.environ.get("MASTERDB_AI_POLICY_VERSION", "0")), ) withdrawn = business.post("/v1/publish/products/withdraw", json=body, headers={"Idempotency-Key": str(uuid.uuid4())}) if withdrawn.status_code != 200: raise SystemExit(f"{withdrawn.json()['code']}: {withdrawn.json().get('detail', '')}") print(withdrawn.json()["record_id"], withdrawn.json()["action"]) assert withdrawn.json()["action"] == "withdraw" ``` `POST /v1/publish/products/withdraw` takes the record out of serving in every region within seconds; its history stays. `POST /v1/publish/products/delete` also purges its bytes within 30 days, keeping only its fingerprints. ## If a key is stolen Revoke it in the portal, with the moment from which it was compromised, or revoke its mandate. Everything sealed with it after that moment is invalid; everything before stands. The push attempts show every record a thief tried, and the `seq` and the caps limited what it could do. --- # Large pushes and bulk files > How a push of more than 50 records is accepted, then processed — polling its status, resuming one that stopped, the bulk-file route for very large loads, and the limits. Source: https://docs.masterdb.ai/businesses/large-pushes/ A push of up to **50 records** is processed while you wait and answered `200` with every record's outcome ([Pushes and mandates](/businesses/pushes/)). A larger push — up to **10,000 records** — is **accepted, then processed**: MasterDB checks everything that could refuse the whole batch, answers `202 Accepted` at once, and checks and publishes the records in the background, 200 at a time. For more than 10,000 records, upload a **bulk file**. Every step below has a runnable sample, in TypeScript and Python, that runs against the sandbox ([The sandbox](/sandbox/)). They share one small file for your key, the product record, sealing and polling; the samples use it, and you can copy it into your own system.
The shared helpers: the key, a product, sealing, sending and polling ```typescript title="samples/typescript/push-kit.ts" // The pieces the large-push samples share: your integration key and client, a product record to send, sealing // a batch, sending it, and reading a push's status until it settles (/businesses/large-pushes/). // // MASTERDB_INTEGRATION_KEY_FILE the integration key's private half (PKCS #8 PEM), as in the push sample // MASTERDB_BUSINESS_UUID your business's public identifier // MASTERDB_AI_POLICY_VERSION your AI policy's version in force (GET /v1/seal-context: ai_policy_version) import { createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { type SealedBatch, SANDBOX, createBusinessClient, createEd25519Signer, createPublicClient, idempotencyKey, sealBatch } from '@masterdb/client'; export const baseUrl = process.env.MASTERDB_API_URL ?? SANDBOX.business; export const signer = createEd25519Signer(createPrivateKey(readFileSync(process.env.MASTERDB_INTEGRATION_KEY_FILE as string))); export const business = createBusinessClient({ baseUrl, signer }); // The seal names the certificate in force: read its cert_id from the public certificate endpoint. const cert = await createPublicClient({ baseUrl }).GET('/v1/certificates/{uuid}', { params: { path: { uuid: process.env.MASTERDB_BUSINESS_UUID as string } } }); if (cert.error) throw new Error(cert.error.code); if (cert.data.cert_id === undefined) throw new Error('the business has no certificate in force'); const certId = cert.data.cert_id; /** * Product `n` of a series. The same series and number are always the same product (the same `business_product_id`), * so a sample that runs again publishes new versions of the same products instead of filling the sandbox. * Money is a decimal string, never a number. */ export function product(series: string, n: number) { const code = `EMBERS-${series}${String(n).padStart(4, '0')}`; return { schema: 'masterdb/products/1', language: 'en', business_product_id: code, product_name: `Ember Spindle Roof Box ${series}${n}`, countries: ['US', 'GB'], vertical: 'automotive', category: 'parts_accessories', channel: 'online', brand: 'Ember Spindle', tags: ['sandbox', 'automotive'], short_description: 'A fictional roof box from the MasterDB sandbox documentation. It does not exist.', prices: [ { country: 'US', currency: 'USD', amount: '189.00' }, { country: 'GB', currency: 'GBP', amount: '149.00' }, ], on_sale: false, availability: 'available', product_url: `https://ember-spindle.sandbox.masterdb.ai/products/${code.toLowerCase()}`, }; } /** One seal over the batch. The bytes you seal are the bytes you send: each record is serialised once, here. */ export function seal(records: object[]): Promise { return sealBatch({ records: records.map((r) => new TextEncoder().encode(JSON.stringify(r))), signer, certId, aiPolicyVersion: Number(process.env.MASTERDB_AI_POLICY_VERSION ?? 0), seq: Date.now(), // must rise with every batch this key sends: a replayed older batch is refused recordType: 'products', }); } /** * Sends a sealed batch (a new request signature each time). Up to 50 records are processed while you wait and * answered 200; more are accepted and answered 202. Either way the answer names the push: read it from there. */ export async function send(body: SealedBatch): Promise<{ pushId: string; http: number }> { const answer = await business.POST('/v1/publish/{type}', { params: { path: { type: 'products' }, header: { 'Idempotency-Key': idempotencyKey() } }, body }); if (answer.error) throw new Error(`${answer.error.code}: ${answer.error.detail ?? ''}`); if (answer.data.push_id === undefined) throw new Error('the answer names no push'); return { pushId: answer.data.push_id, http: answer.response.status }; } /** The push's status and one page of its records' outcomes; `outcome` keeps only the records with that outcome. */ export async function read(pushId: string, query: { outcome?: 'accepted' | 'updated' | 'rejected' | 'held' | 'pending'; cursor?: string } = {}) { const page = await business.GET('/v1/pushes/{push_id}', { params: { path: { push_id: pushId }, query } }); if (page.error) throw new Error(`${page.error.code}: ${page.error.detail ?? ''}`); return page.data; } /** * Reads the status every `poll_after_seconds`, for as long as the answer carries that member; then the push has * settled (`complete`, `held` or `failed`). A push that `failed` or is `stalled` (no step for ten minutes) is * resumed by sending the exact batch again, which `resume` does. */ export async function settle(pushId: string, resume?: () => Promise) { let resumed = 0; for (;;) { const status = await read(pushId); console.log(` ${status.state}: ${status.progress.stage} ${status.progress.chunk}/${status.progress.chunks}, ${status.counts.pending} of ${status.records} records pending`); const stuck = status.state === 'failed' || status.stalled === true; if (stuck && resume !== undefined && resumed < 3) { resumed += 1; console.log(` ${status.state === 'failed' ? `stopped: ${status.error?.code ?? ''}` : 'stalled'}: sending the same batch again`); await resume(); } else if (stuck || status.poll_after_seconds === undefined) { return status; } await new Promise((r) => setTimeout(r, (status.poll_after_seconds ?? 5) * 1000)); } } /** Every record's outcome, in leaf order, 100 a page. */ export async function outcomes(pushId: string, outcome?: 'accepted' | 'updated' | 'rejected' | 'held' | 'pending') { const results = []; for (let cursor: string | undefined; ; ) { const page = await read(pushId, { ...(outcome === undefined ? {} : { outcome }), ...(cursor === undefined ? {} : { cursor }) }); results.push(...page.results); cursor = page.next_cursor; if (cursor === undefined) return results; } } ``` ```python title="samples/python/push_kit.py" """The pieces the large-push samples share: your integration key and client, a product record to send, sealing a batch, sending it, and reading a push's status until it settles (/businesses/large-pushes/). MASTERDB_INTEGRATION_KEY_FILE the integration key's private half (PKCS #8 PEM), as in the push sample MASTERDB_BUSINESS_UUID your business's public identifier MASTERDB_AI_POLICY_VERSION your AI policy's version in force (GET /v1/seal-context: ai_policy_version) """ import json import os import time import uuid import httpx from masterdb_signing import SANDBOX_API, MasterDBAuth, business_tag, load_key, seal_batch base_url = os.environ.get("MASTERDB_API_URL", SANDBOX_API) key = load_key(os.environ["MASTERDB_INTEGRATION_KEY_FILE"]) # A push of up to 500 records may be processed inside its request where background processing is not set up. business = httpx.Client(base_url=base_url, auth=MasterDBAuth(key, tag=business_tag), timeout=90) # The seal names the certificate in force: read its cert_id from the public certificate endpoint. _cert = httpx.get(f"{base_url}/v1/certificates/{os.environ['MASTERDB_BUSINESS_UUID']}") _cert.raise_for_status() cert_id = _cert.json()["cert_id"] def product(series, n): """Product ``n`` of a series. The same series and number are always the same product (the same ``business_product_id``), so a sample that runs again publishes new versions of the same products instead of filling the sandbox. Money is a decimal string, never a number.""" code = f"EMBERS-{series}{n:04d}" return { "schema": "masterdb/products/1", "language": "en", "business_product_id": code, "product_name": f"Ember Spindle Roof Box {series}{n}", "countries": ["US", "GB"], "vertical": "automotive", "category": "parts_accessories", "channel": "online", "brand": "Ember Spindle", "tags": ["sandbox", "automotive"], "short_description": "A fictional roof box from the MasterDB sandbox documentation. It does not exist.", "prices": [{"country": "US", "currency": "USD", "amount": "189.00"}, {"country": "GB", "currency": "GBP", "amount": "149.00"}], "on_sale": False, "availability": "available", "product_url": f"https://ember-spindle.sandbox.masterdb.ai/products/{code.lower()}", } def seal(records): """One seal over the batch. The bytes you seal are the bytes you send: each record is serialised once, here.""" return seal_batch( [json.dumps(r, separators=(",", ":"), ensure_ascii=False).encode("utf-8") for r in records], key, cert_id=cert_id, ai_policy_version=int(os.environ.get("MASTERDB_AI_POLICY_VERSION", "0")), seq=time.time_ns() // 1_000_000, # must rise with every batch this key sends: a replayed older batch is refused ) def send(body): """Sends a sealed batch (a new request signature each time). Up to 50 records are processed while you wait and answered 200; more are accepted and answered 202. Either way the answer names the push: read it from there.""" answer = business.post("/v1/publish/products", json=body, headers={"Idempotency-Key": str(uuid.uuid4())}) if answer.status_code not in (200, 202): raise SystemExit(f"{answer.json()['code']}: {answer.json().get('detail', '')}") return answer.json()["push_id"], answer.status_code def read(push_id, **query): """The push's status and one page of its records' outcomes; ``outcome=`` keeps only the records with that outcome.""" page = business.get(f"/v1/pushes/{push_id}", params=query) page.raise_for_status() return page.json() def settle(push_id, resume=None): """Reads the status every ``poll_after_seconds``, for as long as the answer carries that member; then the push has settled (``complete``, ``held`` or ``failed``). A push that ``failed`` or is ``stalled`` (no step for ten minutes) is resumed by sending the exact batch again, which ``resume`` does.""" resumed = 0 while True: status = read(push_id) c, p = status["counts"], status["progress"] print(f" {status['state']}: {p['stage']} {p['chunk']}/{p['chunks']}, {c['pending']} of {status['records']} records pending") stuck = status["state"] == "failed" or status.get("stalled") is True if stuck and resume is not None and resumed < 3: resumed += 1 print(f" {'stopped: ' + (status['error'] or {}).get('code', '') if status['state'] == 'failed' else 'stalled'}: sending the same batch again") resume() elif stuck or "poll_after_seconds" not in status: return status time.sleep(status.get("poll_after_seconds", 5)) def outcomes(push_id, outcome=None): """Every record's outcome, in leaf order, 100 a page.""" results, cursor = [], None while True: query = {k: v for k, v in (("outcome", outcome), ("cursor", cursor)) if v is not None} page = read(push_id, **query) results.extend(page["results"]) cursor = page.get("next_cursor") if cursor is None: return results ```
## What is checked before the answer Everything that refuses a batch is checked before you get an answer, exactly as for a small push: the request signature, your key and its mandate (record type, countries, caps, source allow-list), that your business is verified, the batch seal, that the records are the ones the seal names (the Merkle root), your certificate and AI policy version, `seq`, and your fair use (below). A batch that fails any of these is refused with the reason, and nothing of it is kept. What happens later is per record: each record is checked against every rule, then published — or rejected with its reasons. One bad record never fails the batch. ## 1. Push Send the same `POST /v1/publish/products` body as for a small push. The answer is `202` with a `Location` header and the push's status: ```json { "push_id": "bat_7d3a4e8f9b6c1a2b3c4d5e6f", "state": "queued", "records": 8000, "counts": { "rejected": 0, "pending": 8000, "accepted": 0, "updated": 0, "held": 0 }, "progress": { "stage": "check", "chunk": 0, "chunks": 40 }, "status_url": "/v1/pushes/bat_7d3a4e8f9b6c1a2b3c4d5e6f", "poll_after_seconds": 5 } ``` The sample below sends 300 records, and polls to `complete` (section 2). ```typescript title="samples/typescript/large-push.ts" // A large push: 300 records are checked at the door and accepted at once (202), checked and published in the // background 200 at a time, and read from the push's status until it is complete (/businesses/large-pushes/). // // Uses the key and helpers of push-kit.ts. Where background processing is not set up, up to 500 records are processed // inside the request and answered 200: the rest of the sample reads the same push status either way. import assert from 'node:assert/strict'; import { outcomes, product, seal, send, settle } from './push-kit.ts'; const records = Array.from({ length: 300 }, (_, i) => product('L', i + 1)); const sent = await send(await seal(records)); console.log(`HTTP ${sent.http}: push ${sent.pushId}`); assert.ok(sent.http === 200 || sent.http === 202, `the push was answered ${sent.http}`); // Poll `GET /v1/pushes/{push_id}` every `poll_after_seconds`, until that member is gone. const done = await settle(sent.pushId); assert.equal(done.state, 'complete'); assert.equal(done.records, 300); assert.equal(done.counts.pending, 0); assert.equal(done.counts.rejected, 0); // `accepted` for a record that was not live, `updated` for a new version of one that was (every run after the first). assert.equal(done.counts.accepted + done.counts.updated, 300); // The per-record outcomes, in leaf order, 100 a page. const all = await outcomes(sent.pushId); assert.equal(all.length, 300); assert.ok(all.every((r, i) => r.leaf_index === i && (r.outcome === 'accepted' || r.outcome === 'updated'))); console.log(`complete: ${done.counts.accepted} accepted, ${done.counts.updated} updated, ${done.counts.rejected} rejected`); ``` ```python title="samples/python/large_push.py" """A large push: 300 records are checked at the door and accepted at once (202), checked and published in the background 200 at a time, and read from the push's status until it is complete (/businesses/large-pushes/). Uses the key and helpers of push_kit.py. Where background processing is not set up, up to 500 records are processed inside the request and answered 200: the rest of the sample reads the same push status either way. """ from push_kit import outcomes, product, seal, send, settle records = [product("L", i + 1) for i in range(300)] push_id, http = send(seal(records)) print(f"HTTP {http}: push {push_id}") assert http in (200, 202), f"the push was answered {http}" # Poll GET /v1/pushes/{push_id} every poll_after_seconds, until that member is gone. done = settle(push_id) counts = done["counts"] assert done["state"] == "complete" assert done["records"] == 300 assert counts["pending"] == 0 and counts["rejected"] == 0 # `accepted` for a record that was not live, `updated` for a new version of one that was (every run after the first). assert counts["accepted"] + counts["updated"] == 300 # The per-record outcomes, in leaf order, 100 a page. results = outcomes(push_id) assert len(results) == 300 assert all(r["leaf_index"] == i and r["outcome"] in ("accepted", "updated") for i, r in enumerate(results)) print(f"complete: {counts['accepted']} accepted, {counts['updated']} updated, {counts['rejected']} rejected") ``` ## 2. Poll `GET /v1/pushes/{push_id}`, signed with `tag="mdb-business-read"` like every read, answers the push's totals, its progress and a page of its records' outcomes in leaf order (100 a page; follow `next_cursor`). Read it again every `poll_after_seconds` while that member is present. - `state` moves `queued` → `processing` → `complete`. `counts.pending` reaches 0 and the other counts add up to `records`. - `held`: the push tripped the price-shock rule; nothing in it is live until an owner or admin confirms it in the Business Portal ([Pushes and mandates](/businesses/pushes/#the-price-shock-hold)). Confirming a large push is processed in the background too; the state then moves `confirming` → `confirmed`. - `stalled: true` (with a `resume` sentence): no step of the push has completed for ten minutes. MasterDB is alerted to a stalled push; you can resume it yourself at once with the next section. The flag is absent while the push is moving and once it has finished. - `failed`: the push stopped on something it cannot get past by itself — your business was put on hold, say — and `error` says what. What it published stays published. - `?outcome=rejected` lists only the rejected records, each with every reason, to fix and send again in a new push (below). Your business's developers also get an email when a large push stops, is held for confirmation, or has rejected records. A push made through the API never sends an email for each product or each push: your developers get one daily summary, and an email at once only when a push needs a person. Each developer chooses whether they get the summary, only the emails that need a person, or neither ([Choosing which emails you get](/businesses/emails/)). If your business has no developer, the emails go to your owners. ### Fix the records that were rejected One bad record never fails the push: it completes with that record `rejected`. This sample sends 60 records, three of them with a price that is a number instead of a decimal string, lists the rejected ones with `?outcome=rejected` — each with its `code`, the JSON `pointer` to the field and a `detail` — and sends the corrected three as a new push with a new `seq`. ```typescript title="samples/typescript/push-rejected.ts" // A large push with records that cannot be published: the push completes, the bad records are `rejected` with every // reason, and you fix just those and send them in a new push (/businesses/large-pushes/). // // Uses the key and helpers of push-kit.ts. import assert from 'node:assert/strict'; import { outcomes, product, seal, send, settle } from './push-kit.ts'; // 60 records; three are wrong on purpose: the price is the number 189, not the string "189.00". const good = Array.from({ length: 60 }, (_, i) => product('R', i + 1)); const wrong = new Set([6, 22, 40]); const records = good.map((r, i) => (wrong.has(i) ? { ...r, prices: [{ country: 'US', currency: 'USD', amount: 189 }] } : r)); const sent = await send(await seal(records)); const done = await settle(sent.pushId); // One bad record never fails the push: it completes, with the other 57 published. assert.equal(done.state, 'complete'); assert.equal(done.counts.rejected, 3); assert.equal(done.counts.accepted + done.counts.updated, 57); // `?outcome=rejected` lists only the rejected records, each with every reason. const rejected = await outcomes(sent.pushId, 'rejected'); assert.deepEqual(rejected.map((r) => r.leaf_index), [6, 22, 40]); for (const r of rejected) { console.log(r.leaf_index, r.business_product_id, JSON.stringify(r.errors?.map((e) => `${e.code} at ${e.pointer ?? '/'}`))); assert.equal(r.errors?.[0]?.code, 'money_not_string'); } // Fix those records (here: the correct versions we kept) and send them alone, as a new batch with a new `seq`. const fixed = rejected.map((r) => good[r.leaf_index] as object); const again = await send(await seal(fixed)); const second = await settle(again.pushId); assert.equal(second.state, 'complete'); assert.equal(second.counts.rejected, 0); assert.equal(second.counts.accepted + second.counts.updated, 3); console.log(`${fixed.length} fixed records published in push ${again.pushId}`); ``` ```python title="samples/python/push_rejected.py" """A large push with records that cannot be published: the push completes, the bad records are `rejected` with every reason, and you fix just those and send them in a new push (/businesses/large-pushes/). Uses the key and helpers of push_kit.py. """ from push_kit import outcomes, product, seal, send, settle # 60 records; three are wrong on purpose: the price is the number 189, not the string "189.00". good = [product("R", i + 1) for i in range(60)] wrong = {6, 22, 40} records = [{**r, "prices": [{"country": "US", "currency": "USD", "amount": 189}]} if i in wrong else r for i, r in enumerate(good)] push_id, _ = send(seal(records)) done = settle(push_id) # One bad record never fails the push: it completes, with the other 57 published. assert done["state"] == "complete" assert done["counts"]["rejected"] == 3 assert done["counts"]["accepted"] + done["counts"]["updated"] == 57 # ?outcome=rejected lists only the rejected records, each with every reason. rejected = outcomes(push_id, "rejected") assert [r["leaf_index"] for r in rejected] == [6, 22, 40] for r in rejected: print(r["leaf_index"], r["business_product_id"], [f"{e['code']} at {e.get('pointer', '/')}" for e in r["errors"]]) assert r["errors"][0]["code"] == "money_not_string" # Fix those records (here: the correct versions we kept) and send them alone, as a new batch with a new seq. fixed = [good[r["leaf_index"]] for r in rejected] again_id, _ = send(seal(fixed)) second = settle(again_id) assert second["state"] == "complete" assert second["counts"]["rejected"] == 0 assert second["counts"]["accepted"] + second["counts"]["updated"] == 3 print(f"{len(fixed)} fixed records published in push {again_id}") ``` A small push's answer also names its `push_id` and `status_url`: you can read any push's status the same way. ## 3. Resume A push that stops (`failed`), or that has shown no progress for more than ten minutes (its status says `stalled: true`), is resumed by sending the **exact batch again** — the same `batch_seal` and the same records, with a new request signature. It is accepted even after the seal's five-minute `sealed_at` window, because MasterDB accepted that batch before. The records already settled keep their outcome; the rest are completed; nothing is published twice. While a push is still moving, sending it again only answers its status. The answer to a large push you send again is `202` with the push's status and its `push_id`, the same one as before. This sample seals one batch, sends it twice as if the first answer was lost, and gets the same push both times; it then polls to the end, resending the same batch if the push `failed` or shows `stalled: true`; and a last send, after the push is complete, changes nothing. ```typescript title="samples/typescript/push-resume.ts" // Resuming a push: when an answer is lost, or a push stops or stalls, send the exact batch again. The same seal and // the same records, a new request signature: you are answered the same push, and nothing is published twice // (/businesses/large-pushes/). // // Uses the key and helpers of push-kit.ts. import assert from 'node:assert/strict'; import { outcomes, product, seal, send, settle } from './push-kit.ts'; const records = Array.from({ length: 300 }, (_, i) => product('S', i + 1)); const body = await seal(records); // sealed once: this exact batch is what you send again const first = await send(body); // Suppose that answer never reached you. Send the exact batch again: a push still moving is only reported (the // same `push_id`, never a second push), and one already finished is answered with its status. const again = await send(body); assert.equal(again.pushId, first.pushId); console.log(`the same batch again: HTTP ${again.http}, the same push ${again.pushId}`); // Poll to the end. A push that fails or stalls (no step for ten minutes) is resumed by the same send. const done = await settle(first.pushId, () => send(body)); assert.equal(done.state, 'complete'); assert.equal(done.counts.pending, 0); assert.equal(done.counts.accepted + done.counts.updated, 300); // Once it is complete, sending it again changes nothing: the same push, the same totals. const after = await send(body); assert.equal(after.pushId, first.pushId); const unchanged = await settle(after.pushId); assert.equal(unchanged.state, 'complete'); assert.deepEqual(unchanged.counts, done.counts); assert.equal((await outcomes(first.pushId)).length, 300); console.log(`complete, and unchanged by sending it again: ${done.counts.accepted} accepted, ${done.counts.updated} updated`); ``` ```python title="samples/python/push_resume.py" """Resuming a push: when an answer is lost, or a push stops or stalls, send the exact batch again. The same seal and the same records, a new request signature: you are answered the same push, and nothing is published twice (/businesses/large-pushes/). Uses the key and helpers of push_kit.py. """ from push_kit import outcomes, product, seal, send, settle records = [product("S", i + 1) for i in range(300)] body = seal(records) # sealed once: this exact batch is what you send again first_id, _ = send(body) # Suppose that answer never reached you. Send the exact batch again: a push still moving is only reported (the # same push_id, never a second push), and one already finished is answered with its status. again_id, http = send(body) assert again_id == first_id print(f"the same batch again: HTTP {http}, the same push {again_id}") # Poll to the end. A push that fails or stalls (no step for ten minutes) is resumed by the same send. done = settle(first_id, lambda: send(body)) assert done["state"] == "complete" assert done["counts"]["pending"] == 0 assert done["counts"]["accepted"] + done["counts"]["updated"] == 300 # Once it is complete, sending it again changes nothing: the same push, the same totals. after_id, _ = send(body) assert after_id == first_id unchanged = settle(after_id) assert unchanged["state"] == "complete" assert unchanged["counts"] == done["counts"] assert len(outcomes(first_id)) == 300 print(f"complete, and unchanged by sending it again: {done['counts']['accepted']} accepted, {done['counts']['updated']} updated") ``` ## Bulk files: up to 100,000 records 1. **Ask for an upload**: `POST /v1/bulk-uploads` with `{"record_type": "products"}`, signed with `tag="mdb-push"`. The answer is a signed upload: a `url` and its `fields`, valid for an hour, for one file of at most 256 MiB. 2. **Upload the file** straight to that `url` as `multipart/form-data`: every member of `fields`, then the file as `file`. The file is **one record a line, each line the base64 of the record's exact bytes** (`application/x-ndjson`). Line 1 is leaf 0 of your batch seal's Merkle tree. 3. **Import it**: `POST /v1/bulk-imports` with `{"upload_id": "…", "batch_seal": {…}}` — the same batch seal as a push's, over the file's records (`tree_size` the number of lines). The answer is `202` with the import's status; poll it as above. This sample uploads 100 records and imports them (a bulk file may hold up to 100,000), then polls the import like any push; its status has `source: "bulk"`. ```typescript title="samples/typescript/bulk-import.ts" // A bulk file: ask for a signed upload, upload one NDJSON file (one record a line, each the base64 of the record's // exact bytes), then import it under the batch seal over its records and poll it (/businesses/large-pushes/). // // Uses the key and helpers of push-kit.ts. The file here is 100 records; a bulk file may hold up to 100,000. import assert from 'node:assert/strict'; import { idempotencyKey } from '@masterdb/client'; import { business, outcomes, product, seal, settle } from './push-kit.ts'; const body = await seal(Array.from({ length: 100 }, (_, i) => product('B', i + 1))); // 1. Ask for an upload: a signed POST (a `url` and its `fields`, valid for an hour) for one file. const slot = await business.POST('/v1/bulk-uploads', { params: { header: { 'Idempotency-Key': idempotencyKey() } }, body: { record_type: 'products' } }); if (slot.error) throw new Error(`${slot.error.code}: ${slot.error.detail ?? ''}`); // 2. Upload the file straight to that `url` as multipart/form-data: every member of `fields`, then the file as `file`. // Line i is leaf i of the seal's Merkle tree: `body.records` are already the base64 lines. const form = new FormData(); for (const [name, value] of Object.entries(slot.data.fields)) form.append(name, value); form.append('file', new Blob([`${body.records.join('\n')}\n`], { type: slot.data.content_type }), 'records.ndjson'); const uploaded = await fetch(slot.data.url, { method: 'POST', body: form }); assert.ok(uploaded.ok, `the upload was answered ${uploaded.status}`); // 3. Import it: the upload and the seal over its records. Answered 202 with the import's status. const imported = await business.POST('/v1/bulk-imports', { params: { header: { 'Idempotency-Key': idempotencyKey() } }, body: { upload_id: slot.data.upload_id, batch_seal: body.batch_seal }, }); if (imported.error) throw new Error(`${imported.error.code}: ${imported.error.detail ?? ''}`); assert.equal(imported.response.status, 202); console.log(`import ${imported.data.push_id} of upload ${slot.data.upload_id}`); // Poll it as any push: the file is read once in the background, then its records are checked and published. const done = await settle(imported.data.push_id); assert.equal(done.state, 'complete'); assert.equal(done.source, 'bulk'); assert.equal(done.counts.accepted + done.counts.updated, 100); assert.equal((await outcomes(imported.data.push_id)).length, 100); console.log(`complete: ${done.counts.accepted} accepted, ${done.counts.updated} updated`); ``` ```python title="samples/python/bulk_import.py" """A bulk file: ask for a signed upload, upload one NDJSON file (one record a line, each the base64 of the record's exact bytes), then import it under the batch seal over its records and poll it (/businesses/large-pushes/). Uses the key and helpers of push_kit.py. The file here is 100 records; a bulk file may hold up to 100,000. """ import uuid import httpx from push_kit import business, outcomes, product, seal, settle body = seal([product("B", i + 1) for i in range(100)]) # 1. Ask for an upload: a signed POST (a url and its fields, valid for an hour) for one file. slot = business.post("/v1/bulk-uploads", json={"record_type": "products"}, headers={"Idempotency-Key": str(uuid.uuid4())}) if slot.status_code != 201: raise SystemExit(f"{slot.json()['code']}: {slot.json().get('detail', '')}") slot = slot.json() # 2. Upload the file straight to that url as multipart/form-data: every member of fields, then the file as `file`. # Line i is leaf i of the seal's Merkle tree: body["records"] are already the base64 lines. ndjson = ("\n".join(body["records"]) + "\n").encode("ascii") uploaded = httpx.post(slot["url"], data=slot["fields"], files={"file": ("records.ndjson", ndjson, slot["content_type"])}, timeout=60) assert uploaded.is_success, f"the upload was answered {uploaded.status_code}" # 3. Import it: the upload and the seal over its records. Answered 202 with the import's status. imported = business.post( "/v1/bulk-imports", json={"upload_id": slot["upload_id"], "batch_seal": body["batch_seal"]}, headers={"Idempotency-Key": str(uuid.uuid4())}, ) if imported.status_code != 202: raise SystemExit(f"{imported.json()['code']}: {imported.json().get('detail', '')}") push_id = imported.json()["push_id"] print(f"import {push_id} of upload {slot['upload_id']}") # Poll it as any push: the file is read once in the background, then its records are checked and published. done = settle(push_id) assert done["state"] == "complete" assert done["source"] == "bulk" assert done["counts"]["accepted"] + done["counts"]["updated"] == 100 assert len(outcomes(push_id)) == 100 print(f"complete: {done['counts']['accepted']} accepted, {done['counts']['updated']} updated") ``` The file is read once in the background. If its records are not exactly the ones your seal names, the import is `failed` with `seal_invalid` before any record is checked. Uploaded files are deleted as soon as their import completes, and after 7 days in any case. Sending the same import request again answers its status, and resumes one that stopped. ## Limits | | Limit | Answer when over | |---|---|---| | Records in one push | 10,000 (50 or fewer: answered inline) | `400` `request_invalid` | | Request body | 32 MiB | `413` | | One record | 153,600 bytes (204,800 base64 characters) | that record `rejected` | | Records in one bulk file | 100,000 | `400` `limit_exceeded` | | One bulk file | 256 MiB | `400` `limit_exceeded` | | Your key's records an hour and bytes a day | as your mandate states | `429` `rate_limited`, `cap: records_per_hour` or `bytes_per_day` | | Large pushes and bulk imports in progress at once, per business | 3 | `429` `rate_limited`, `cap: concurrent_pushes`, `Retry-After: 60` | | Records an hour, per business, across all its keys | 250,000 | `429` `rate_limited`, `cap: business_records_per_hour`, retry at the next hour | A `429` carries `Retry-After` and the problem's `retry_after_seconds`, `cap` and `limit`. ## Retrying a `503` A `503` with `Retry-After` on a push or a bulk route is always safe to retry with the same batch: it is the same request, and a push that was already accepted is never processed twice. --- # Imports > A CSV import merges into your product drafts — only the columns you provide — and publishes nothing until a person reviews the result and seals it. Source: https://docs.masterdb.ai/businesses/imports/ A push replaces; **an import merges**. An import brings a spreadsheet of products into your **drafts** in the Business Portal. Nothing is published until a person reviews the drafts and seals them ([Publishing through the portal](/businesses/portal-publishing/)). ## How it works 1. **In the portal**, choose the file and map its column headings to fields. The file is read in your browser. 2. The portal sends **every row** — valid and invalid, with the problems it found — each with the columns your file actually has. 3. MasterDB validates every row itself: every rule a record meets when published, and the scope rule, on the columns the row provides; a new product must also be complete. 4. Each row is matched to a draft by your own `business_product_id`: - a **new** product becomes a new draft; - an **existing** one is merged: **only the columns your file provides** change. A provided empty cell clears that field; a column your file does not have is left as it was. 5. You see the result — every row's outcome and, for each draft, the diff since it was last published — and seal what you want to publish. Row errors never fail the import: the good rows land, and the bad rows are listed with their reasons. Running the same file again answers `updated` for each row and never makes duplicates. A large file is sent in parts of up to 2,000 rows. ## Images in an import An image set by hand in the portal survives an import that omits the image columns. An image that comes from an import is marked as a feed image, and a later hand-set image replaces it. ## Imports and pushes together If your system pushes products (Path A) and people also import or edit them, the last thing sealed wins, whichever way it came: a push replaces the record, and the draft is refreshed from it so the portal shows what is live. --- # Images > How images are uploaded, checked, re-encoded and served — and what AI companies can learn from them. Source: https://docs.masterdb.ai/businesses/images/ Product images, logos, and event and job images are uploaded in the Business Portal, checked as hostile until proven otherwise, and re-encoded from pixels before anything is stored where it can be served. ## Uploading 1. The portal asks for a **signed upload policy** for one image: its purpose, its type — JPEG, PNG or WebP — and its size, at most 10 MB. The image goes straight to private storage, never through the API, and the raw upload is never served. 2. The person chooses a **crop**. One crop rectangle gives both shapes AI companies render: **1:1** and **1.91:1**. 3. The portal asks MasterDB to process the upload with that crop. ## What processing does - The type is read from the file's own bytes, not its name or its declared type. **SVG, XML, HTML and GIF are refused** (`image_type_refused`). - The dimensions are read before any pixel is decoded, so a decompression bomb is refused before it can do harm. - The image is decoded in an isolated worker with pixel, memory and time limits (`image_invalid` otherwise). - Every variant — the image itself, at most 2,048 pixels a side, and the two crops — is **re-encoded from pixels, with all metadata stripped**: no EXIF location, no XMP, no colour profiles, no text chunks. - Each variant is stored under its own SHA-256 and served from MasterDB's CDN, cacheable for a year; the URL goes into your draft. Processing the same upload again answers the same variants. ## Images you host A pushed product may name an `image_url` on your own site. It must be a safe URL — no private or local addresses — and like every URL you publish it is checked for malware and phishing at publish and daily afterwards; a flagged URL takes the row out of serving and opens a review. ## What an image tells MasterDB AI companies fetch images to render them, so image fetches are counted. They are a lower bound on renders, not an observation of every one. --- # Geocoding > How an address in a Business & Brand file, event or job becomes a coordinate — in MasterDB's signed sidecar, never in your sealed bytes — and when a coordinate is held for review. Source: https://docs.masterdb.ai/businesses/geocoding/ AI companies search by place — "near this point, within 5 km" — so every record with an address needs a coordinate. You publish the **address**; MasterDB works out the **coordinate** and records it beside your record, never inside it. ## How it works - In the portal, an address is confirmed with an address search, which also captures the place's identifier (`place_id`). - At publish, MasterDB geocodes the confirmed address and records the coordinate and where it came from in the record's **sidecar** — MasterDB's own signed statement, stored with your record. Your sealed bytes are never changed: a seal covers exactly what you published. - Search rows carry the coordinate, so a search with `near` finds the record; a fetch returns the sidecar, so an AI company can see which coordinate came from where. ## The checks A coordinate that falls **outside the record's country**, or **more than 500 m from the place's own point** when a place was captured, is not published. The record is **held**: stored, not served, and opened for review. This is the check that stops a wrong or tampered coordinate reaching AI companies. See [Holds](/businesses/holds/). ## What to publish Publish the address as your customers would write it, in the country the record is published for. Do not put coordinates in your record's text. --- # Your AI policy > The sealed record of what you permit AIs to do with your data and on your behalf, and the contexts you do not want it used in — how it is set, how it reaches every AI company, and what it can and cannot enforce. Source: https://docs.masterdb.ai/businesses/ai-policy/ Your **AI policy** says, in named on-and-off choices, what you permit and where you do not want your data used. It is a record like any other: drafted on the Business Portal's AI policy screen and **sealed by a person** with their passkey. Change it in one save, and every AI company sees the change on its next request — without re-sealing a single product. ## The choices | Choice | When on, you say | Default | |---|---|---| | `answer` | AIs may use your data to answer | on | | `quote` | AIs may state a price as your current price | on | | `reserve` | AIs may make a booking or reservation on a person's behalf | off | | `purchase` | AIs may complete a purchase on a person's behalf, through a checkout domain you have authorised | off | | `contact` | AIs may contact you on a person's behalf | off | | `hand_to_human` | AIs must hand the person to you for anything beyond answering | on | | `cite_as_source` | cite you as the source | on | | `definitive_source` | where your record conflicts with a third party, yours wins | on | | `prefer_over_inference` | a field you left out means *not stated*: AIs must not infer or reconstruct it | on | | `include_in_recommendations` | you may be included in recommendations | on | | `quote_policy_verbatim` | your delivery, returns and warranty wording is quoted verbatim | off | | `prices_indicative` | your prices are indicative | off | | `state_publish_date` | AIs say when your information was last published | on | `reserve` and `purchase` can be switched on once MasterDB has completed the additional checks they need; the Business Portal shows when they are available to you. ## Contexts where your data must not be used Ten subject areas where you do not want your published data used to build an AI's response, even when your record would be relevant. Each is off until you switch it on. On the API each travels as a short code (`bc1`–`bc10`) whose meaning is published and never changes. | Code | Context | |---|---| | `bc1` | Adult and sexual content | | `bc2` | Alcohol | | `bc3` | Crime and illegal activity | | `bc4` | Death, tragedy and disaster | | `bc5` | Firearms, weapons and violence | | `bc6` | Gambling and betting | | `bc7` | Mental health, self-harm and crisis | | `bc8` | Politics and elections | | `bc9` | Regulated advice (medical, legal or financial) | | `bc10` | Tobacco, vaping and recreational drugs | A blocked context is a request about *where* your data is used, not a statement about your business. ## How it reaches AI companies - Every **search row** carries your AI policy in force as compact bits, with the version, so an AI company answering from the row alone knows what you permit and which contexts you block. - Every **fetch** carries your sealed AI policy record itself, which proves those bits. - Every **receipt** names the version applied to each row. The version in force when MasterDB served a record governs; if a region has not yet received your change, the version on the receipt governs and the lag is MasterDB's, not the AI company's. - Every other record you seal names the AI policy version in force when you sealed it (`ai_policy_version` in the seal; a pushing system reads it from `GET /v1/seal-context`). Before you seal an AI policy, the version is 0: nothing beyond the AI-company Terms is affirmatively permitted, and no context is blocked. ## What your AI policy can and cannot do Your AI policy is binding on AI companies through the AI-company Terms they have signed, and the receipts show what each was served under which version. **Nothing cryptographic can stop an AI that ignores it**: MasterDB learns of a breach only from evidence, and the remedy is contractual. Blocking an AI company is the one control that takes effect without anyone's cooperation — see [What an AI company sees](/businesses/what-ai-companies-see/). --- # Holds > The three kinds of hold — a push held for your confirmation, a record held for review, and a business on hold — what each stops, what keeps working, and how each is lifted. Source: https://docs.masterdb.ai/businesses/holds/ A **hold** stops something from going live without taking down what is already live. There are three kinds. ## A push held for your confirmation A push from your own system that would, on a catalogue of 20 or more listings, change more than a fifth of its prices, change any price by more than half, or remove more than a fifth of its listings, is **stored but not published**. The push answers `held: true`, and the portal shows it to your owners and admins. An owner or admin — someone allowed to manage mandates — reviews it and **confirms** it, and its records go live. A version overtaken while it waited (because a newer one went live) is not published over the newer one: it answers `rejected` with `conflict`. This is the check a stolen integration key cannot pass. ## A record held for review A record whose geocoded coordinate falls outside its country, or more than 500 m from the place captured with its address, is **stored and not served**, and opened for review by MasterDB. See [Geocoding](/businesses/geocoding/). A record whose URL is flagged as unsafe (malware or phishing) is taken out of serving and reviewed in the same way. ## A business on hold MasterDB may put a business **on hold** while a case is open — a verification question, a report, a payment dispute. On hold: - **your published records stay live**, and AI companies see no difference; - **new publishing pauses**: a save or a push is refused with `party_held` (`403`); - **you can still** edit drafts, run imports, and withdraw or delete records; - ad delivery pauses. A hold is a record with a reason and a date it was lifted, never a silent flag. It is different from a **suspension that withdraws published data**, a MasterDB decision that takes every record out of serving in every region at once — and is lifted the same way. --- # What an AI company sees > Exactly what AI companies receive about your business, what they never receive, how they find you, and how blocking works from your side. Source: https://docs.masterdb.ai/businesses/what-ai-companies-see/ ## What they receive | Where | What | |---|---| | A search row | the short, signed projection of one record: name, prices by country, category, dates, location, your business name, the record's origin hash, and your AI policy bits in force (what you permit, and the contexts you block) | | A fetch | your record **exactly as you sealed it**, your seal, MasterDB's signed sidecar, your sealed AI policy, and — for your Business & Brand file — the texts you wrote about how you want to be represented | | Your certificate | public to anyone: your legal name, your country, that MasterDB verified you and since when, and your status. Nothing about how you were verified | ## What they never receive - Anything about the people at your business: no names, no roles, no grants, no key holders. No response names a person. - Your internal identifiers (your account's id inside MasterDB). - Net or gross amounts, take rates, or anything about your money. - Whether you have blocked them. - How you were verified. ## How they find you AI companies search with their own queries, filters and sort. **MasterDB never ranks**: there is no default order and no relevance score MasterDB chooses. What decides whether your record is found is what you published — accurate categories, prices in each country you sell in, clear names and descriptions — and the query the AI company wrote. Paid placements (sponsored items, ads) are always marked as paid and never change the order of a search. At most 50 rows answer any search, with no second page. A business with a large catalogue is found by the details that distinguish its products. ## Who is asking Your analytics show which AI companies are asking about you — by name, by country — and what they fetch. `GET /v1/analytics/who-is-asking` answers the same to your own systems. ## Blocking an AI company An owner or admin can **block an AI company** in the portal. The block applies to the company's whole group — every affiliate and every key, including keys it registers later — in every region within seconds. From then on its searches never include your records, and its fetches of them are answered exactly as a record that never existed. **The blocked company is never told.** Nothing in any response or in its portal says so. Unblocking is the same one action in reverse. Two limits: a blocked company could infer a block from outside MasterDB — by comparing results with another company, or from your own website — and blocking does not recall what it retrieved before the block. See [the security model](/threat-model/). --- # Choosing which emails you get > Each person chooses how MasterDB emails them — each time, grouped, a daily summary or off — in Settings → Notifications. Source: https://docs.masterdb.ai/businesses/emails/ MasterDB emails the people at your business when something happens that they should know about. **Each person chooses how they get these emails**, for themselves, in the portal under **Settings → Notifications**. Nobody can change anyone else's choices, and a choice applies on every account the person belongs to. The AI Portal has the same page for the people of an AI company. Every email ends with a link, **Choose which emails you get**, that opens that page. ## The choices | Emails about | Who gets them | Choices | If you never choose | |---|---|---|---| | **Publishing confirmations**: a person on your team publishes, withdraws or deletes from the portal, or finishes a spreadsheet import | Owners and admins (and the person who ran an import) | Each time · Grouped every 10 minutes · Daily summary · Off | Grouped every 10 minutes | | **API publishing**: what your systems published through the API | Developers (your owners when you have none) | Daily summary · Only when something needs a person · Off | Daily summary | | **Billing**: invoices, statements, payments, a low balance, vouchers, payouts | Billing managers (your owners when you have none) | Each time · Daily summary | Each time | | **Ads**: ad decisions and campaign updates | Ad managers (your owners when you have none) | Each time · Daily summary · Off | Each time | | **Messages from colleagues**: a new message in a conversation on your account | The people in the conversation | Each time · Daily summary · Off | Each time | | **Security**: sign-in links and codes, new keys and passkeys, publishing locked and unlocked, account changes | Whoever it concerns | Always sent | Always sent | - **Grouped every 10 minutes**: one person's changes are gathered into one email, sent 10 to 15 minutes after their first change, naming the person and listing each record. - **Daily summary**: one email a day, at 07:00 UTC, for the day before, with each item and its time. There is one summary for each account and each kind of email, so one account's activity never appears in another's. - **Only when something needs a person** (API publishing): an email at once when a push stops, is held for confirmation or rejects records, or when an API mandate is about to end — and no daily summary. **Daily summary** sends both. - **Off**: no email. What happened still shows in the portal's notices. ## What never changes - **Security emails are always sent.** They are how a sign-in or a change nobody expected is noticed, so they cannot be turned off or delayed. This includes the email after each standard publish, which carries the link to withdraw the record and lock publishing. - **The confirmation code** for standard publishing goes to the person acting only, at once. - **MasterDB never asks for a code or a password by email**, and every email says so. ## Changing your choices 1. Open the portal and go to **Settings → Notifications** (or follow **Choose which emails you get** in any email). 2. Pick a choice from the list on the row you want to change. It is saved straight away, and the page says so. A new choice applies to the next email. Anything already gathered for a daily summary is still sent the next morning, unless you choose **Off**. --- # MCP for businesses > The hosted business-side MCP server — draft, publish (which hands you to the portal to seal), status and receipts — which can never seal; and machine publishing with an integration key. Source: https://docs.masterdb.ai/businesses/mcp/ A business can connect its own MCP client — a chat assistant, a copilot, an internal tool — to MasterDB to prepare its records and see how they are doing. **Sealed, or not published** holds on every transport: nothing reaches AI companies through MCP without a person's passkey. ## The hosted server `POST https://mcp.business.masterdb.ai/v1/mcp/business`, over MCP's Streamable HTTP transport, signed in **as a person of the business** — the same sign-in as the Business Portal, with the same roles. | Tool | Does | |---|---| | `draft` | creates or edits a draft and validates it, with every reason at once | | `publish` | validates the draft, shows the change since the last publish, and returns a **link to the Business Portal's save screen** — where the person reviews it and seals it with their passkey. The server never seals. | | `status` | your drafts, what is published, and where each record is live | | `receipts_for_my_records` | your publish receipts, and which AI companies are asking | Every call runs under the person's own permissions. This server can never seal, spend, change people, place or lift a block, or change keys: those stay in the portal, behind a passkey. Sessions work as on the AI companies' hosted servers: an `Mcp-Session-Id` from `initialize`, 30 minutes idle, `DELETE` to end one. The endpoint is in the [reference](/reference/mcp/). ## Machine publishing A business whose own systems publish uses an integration key under a mandate, exactly as any connector does: see [Pushes and mandates](/businesses/pushes/). --- # API reference > The reference for MasterDB's public APIs, generated from their OpenAPI 3.1 documents on every build — never hand-written — with the conventions every API shares. Source: https://docs.masterdb.ai/reference/ The reference is generated from the OpenAPI 3.1 documents the services are tested against. | API | Host | Who calls it | Reference | OpenAPI | |---|---|---|---|---| | Retrieval | `api.masterdb.ai` | an AI company's systems, every request signed | [Retrieval API](/reference/retrieval/) | [retrieval.json](/reference/retrieval.json) | | Verification | `verify.masterdb.ai` (and the same paths on `api.masterdb.ai`) | anyone, no account | [Verification API](/reference/public/) | [public.json](/reference/public.json) | | Business | `api.masterdb.ai` | a business's own systems, signed with an integration key | [Business API](/reference/business/) | [business.json](/reference/business.json) | | Hosted MCP endpoints | `mcp.masterdb.ai`, `mcp.business.masterdb.ai` | MCP clients ([MCP servers](/ai-companies/mcp/)) | [Hosted MCP endpoints](/reference/mcp/) | [mcp.json](/reference/mcp.json) | The sandbox serves each under `sandbox.api.masterdb.ai` ([The sandbox](/sandbox/)). The portals' own APIs are not public interfaces and are not documented here. Also generated on every build: - [Fields, filters and sort keys](/reference/search-fields/) — every collection's allow-list. - [Error codes](/reference/errors/) — every stable code, its status and meaning. - [Test vectors](/reference/test-vectors/) — the files every verifier is held to. ## Postman A Postman collection of the retrieval, verification and business APIs, generated from the same OpenAPI documents, with an environment for the sandbox and one for production: [collection](/postman/masterdb.postman_collection.json) · [sandbox environment](/postman/masterdb-sandbox.postman_environment.json) · [production environment](/postman/masterdb-production.postman_environment.json). Its pre-request script signs every request with RFC 9421 from the variable `signing_key`, and **signs only with a test key** made by `masterdb keygen` ([CLI](/ai-companies/cli/)): paste the private JWK it prints, after taking its public half as a sandbox key in the AI Portal ([The sandbox](/sandbox/#taking-a-sandbox-key-in-the-ai-portal)) and setting your key's grant as the value of the `MDB-Sandbox-Key` header. Any other key is refused. A production key belongs in your own signing system, never in a Postman variable — in production, use the SDK. The verification API needs no key. ## Conventions on every API - **A version in the path** (`/v1/`). Within a version, changes are additive only: new endpoints, new optional request members, new response members. Ignore members you do not know. See the [deprecation policy](/deprecation-policy/). - **Errors are RFC 9457 problems** with a stable `code` ([Error codes](/reference/errors/)). - **`Idempotency-Key`** on every `POST` that creates something. - **Timestamps** are RFC 3339 in UTC; **money** is a decimal string with its currency, never a floating-point number, in every request and every record. (Search rows, which are an index projection, carry prices as numbers.) - **One host per trust boundary**, so the read path never sits behind the same door as a portal session. ## What is absent on purpose There is no list-all, no bulk fetch and no export of records to an AI company; no endpoint that says how a business was verified, a take rate, or gross or net amounts across the two sides; and no endpoint that publishes without a seal. --- # Fields, filters and sort keys > For each collection, the text fields a query can match, every filter field with its operators, and every sort key. Generated from the allow-lists the retrieval service enforces. Source: https://docs.masterdb.ai/reference/search-fields/ A search names one collection, **exactly one country**, and a sort. Only the fields, operators and sort keys below exist: anything else is refused with `unknown_field`, `operator_not_allowed` or `sort_not_allowed`, never rewritten. Limits on every search: at most 50 rows, 20 filter clauses, 10 values in an `in`, 3 sort keys, 512 characters of `q` and 256 of any string value. Every clause is an object with one member, the field name. `{"on_sale": false}` is shorthand for `{"on_sale": {"eq": false}}`. The `country` clause selects the rows published for that country; on products, `price_amount` and `price_currency` then mean that country's price. See [Search](/ai-companies/search/) for the shape of a request. ## `products` Typed address: `POST /v1/products/search`. Text fields for `query_by`: `product_name`, `short_description`, `tags`, `brand`. Sort keys: `_text_match`, `price_amount`, `published_at`. | Filter field | Type and operators | |---|---| | `country` | ISO 3166-1 alpha-2; exactly one per search | | `language` | string; operators: eq, in. `{"language": v}` is shorthand for `{"language": {"eq": v}}`. | | `tags` | string; operators: eq, in. `{"tags": v}` is shorthand for `{"tags": {"eq": v}}`. | | `brand` | string; operators: eq, ne, in, nin. `{"brand": v}` is shorthand for `{"brand": {"eq": v}}`. | | `vertical` | string; operators: eq, ne, in, nin. `{"vertical": v}` is shorthand for `{"vertical": {"eq": v}}`. | | `category` | string; operators: eq, ne, in, nin. `{"category": v}` is shorthand for `{"category": {"eq": v}}`. | | `channel` | string; operators: eq, ne, in, nin. `{"channel": v}` is shorthand for `{"channel": {"eq": v}}`. | | `availability` | string; operators: eq, ne, in, nin. `{"availability": v}` is shorthand for `{"availability": {"eq": v}}`. | | `on_sale` | bool; operators: eq. `{"on_sale": v}` is shorthand for `{"on_sale": {"eq": v}}`. | | `price_amount` | money; operators: eq, lt, lte, gt, gte; also a sort key; applies to the searched country's value (indexed as `price_{CC}`). `{"price_amount": v}` is shorthand for `{"price_amount": {"eq": v}}`. | | `price_currency` | currency; operators: eq, ne, in; applies to the searched country's value (indexed as `price_currency_{CC}`). `{"price_currency": v}` is shorthand for `{"price_currency": {"eq": v}}`. | | `gtin` | string; operators: eq, in. `{"gtin": v}` is shorthand for `{"gtin": {"eq": v}}`. | | `sponsored` | bool; operators: eq. `{"sponsored": v}` is shorthand for `{"sponsored": {"eq": v}}`. | | `published_at` | timestamp; operators: lt, lte, gt, gte; also a sort key. | ## `business_files` Typed address: `POST /v1/business-files/search`. Text fields for `query_by`: `description`, `brands_owned.name`, `brands_sold.name`, `markets_served`. Sort keys: `_text_match`, `year_established`, `published_at`. | Filter field | Type and operators | |---|---| | `country` | ISO 3166-1 alpha-2; exactly one per search | | `language` | string; operators: eq, in. `{"language": v}` is shorthand for `{"language": {"eq": v}}`. | | `has_locations` | bool; operators: eq. `{"has_locations": v}` is shorthand for `{"has_locations": {"eq": v}}`. | | `location_geo` | geopoint; operators: near. | | `cities` | string; operators: eq, in. `{"cities": v}` is shorthand for `{"cities": {"eq": v}}`. | | `delivery.options` | string; operators: eq, in. `{"delivery.options": v}` is shorthand for `{"delivery.options": {"eq": v}}`. | | `ships_internationally` | bool; operators: eq. `{"ships_internationally": v}` is shorthand for `{"ships_internationally": {"eq": v}}`. | | `payment.methods` | string; operators: eq, in. `{"payment.methods": v}` is shorthand for `{"payment.methods": {"eq": v}}`. | | `has_booking` | bool; operators: eq; always false on every row, so has_booking: true matches nothing. `{"has_booking": v}` is shorthand for `{"has_booking": {"eq": v}}`. | | `year_established` | int; operators: eq, lt, lte, gt, gte; also a sort key. `{"year_established": v}` is shorthand for `{"year_established": {"eq": v}}`. | | `published_at` | timestamp; operators: lt, lte, gt, gte; also a sort key. | ## `events` Typed address: `POST /v1/events/search`. Text fields for `query_by`: `title`, `description`. Sort keys: `_text_match`, `start_at`, `end_at`, `price_min`, `price_max`, `published_at`. | Filter field | Type and operators | |---|---| | `country` | ISO 3166-1 alpha-2; exactly one per search | | `language` | string; operators: eq, in. `{"language": v}` is shorthand for `{"language": {"eq": v}}`. | | `region` | string; operators: eq, in. `{"region": v}` is shorthand for `{"region": {"eq": v}}`. | | `event_type` | string; operators: eq, ne, in, nin. `{"event_type": v}` is shorthand for `{"event_type": {"eq": v}}`. | | `attendance_mode` | string; operators: eq, ne, in. `{"attendance_mode": v}` is shorthand for `{"attendance_mode": {"eq": v}}`. | | `start_at` | timestamp; operators: lt, lte, gt, gte; also a sort key. | | `end_at` | timestamp; operators: lt, lte, gt, gte; also a sort key. | | `timezone` | string; operators: eq, in. `{"timezone": v}` is shorthand for `{"timezone": {"eq": v}}`. | | `geo` | geopoint; operators: near. | | `city` | string; operators: eq, in. `{"city": v}` is shorthand for `{"city": {"eq": v}}`. | | `ticketed` | bool; operators: eq. `{"ticketed": v}` is shorthand for `{"ticketed": {"eq": v}}`. | | `price_min` | money; operators: lt, lte, gt, gte; also a sort key. | | `price_max` | money; operators: lt, lte, gt, gte; also a sort key. | | `price_currency` | currency; operators: eq, in. `{"price_currency": v}` is shorthand for `{"price_currency": {"eq": v}}`. | | `age_restriction` | string; operators: eq, ne, in. `{"age_restriction": v}` is shorthand for `{"age_restriction": {"eq": v}}`. | | `published_at` | timestamp; operators: lt, lte, gt, gte; also a sort key. | ## `jobs` Typed address: `POST /v1/jobs/search`. Text fields for `query_by`: `title`, `description`. Sort keys: `_text_match`, `salary_min`, `salary_max`, `closing_date`, `published_at`. | Filter field | Type and operators | |---|---| | `country` | ISO 3166-1 alpha-2; exactly one per search | | `language` | string; operators: eq, in. `{"language": v}` is shorthand for `{"language": {"eq": v}}`. | | `salary_disclosed` | bool; operators: eq. `{"salary_disclosed": v}` is shorthand for `{"salary_disclosed": {"eq": v}}`. | | `employment_type` | string; operators: eq, ne, in, nin. `{"employment_type": v}` is shorthand for `{"employment_type": {"eq": v}}`. | | `category` | string; operators: eq, ne, in, nin. `{"category": v}` is shorthand for `{"category": {"eq": v}}`. | | `work_arrangement` | string; operators: eq, ne, in. `{"work_arrangement": v}` is shorthand for `{"work_arrangement": {"eq": v}}`. | | `geo` | geopoint; operators: near. | | `city` | string; operators: eq, in. `{"city": v}` is shorthand for `{"city": {"eq": v}}`. | | `region` | string; operators: eq, in. `{"region": v}` is shorthand for `{"region": {"eq": v}}`. | | `salary_min` | money; operators: lt, lte, gt, gte; also a sort key. | | `salary_max` | money; operators: lt, lte, gt, gte; also a sort key. | | `salary_currency` | currency; operators: eq, in. `{"salary_currency": v}` is shorthand for `{"salary_currency": {"eq": v}}`. | | `salary_period` | string; operators: eq, in. `{"salary_period": v}` is shorthand for `{"salary_period": {"eq": v}}`. | | `closing_date` | timestamp; operators: lt, lte, gt, gte; also a sort key. | | `published_at` | timestamp; operators: lt, lte, gt, gte; also a sort key. | ## `updates` Typed address: `POST /v1/updates/search`. Text fields for `query_by`: `headline`, `body`. Sort keys: `_text_match`, `published_at`, `relevant_until`. | Filter field | Type and operators | |---|---| | `country` | ISO 3166-1 alpha-2; exactly one per search | | `language` | string; operators: eq, in. `{"language": v}` is shorthand for `{"language": {"eq": v}}`. | | `update_type` | string; operators: eq, ne, in, nin. `{"update_type": v}` is shorthand for `{"update_type": {"eq": v}}`. | | `published_at` | timestamp; operators: lt, lte, gt, gte; also a sort key. | | `relevant_until` | timestamp; operators: lt, lte, gt, gte; also a sort key. | --- # AI policy key > The key to the ai_policy_bits every search row carries — each bit of the use, action and blocked masks by name, and the blocked-context codes bc1–bc10 with their meanings. Served at GET /v1/ai-policy-key. Source: https://docs.masterdb.ai/reference/ai-policy-key/ Every search row carries `ai_policy_bits: {ai_policy_version, ai_policy_schema, use, action, blocked}` — the business's sealed [AI policy](/ai-companies/ai-policy/) as bitmasks. This page is the key. The same key is served, public and unauthenticated, at **`GET /v1/ai-policy-key`** (cacheable for a day), and is built into `@masterdb/shared` and both [verifier libraries](/ai-companies/verifier-libraries/). **The rule.** A bit is set exactly when the sealed boolean of that name is true. Positions are fixed per `ai_policy_schema` and only ever appended. A blocked-context code `bc{n}` is bit *n*−1 of the `blocked` mask and is **never reused or renamed**; a context that is ever retired keeps its number. At `ai_policy_version` 0 (no sealed AI policy) every mask is 0. ## Blocked contexts (`blocked`) | Code | Number | Bit | Name | Meaning | Since schema | |---|---|---|---|---|---| | `bc1` | 1 | 0 | `adult_sexual` | Adult and sexual content | 2 | | `bc2` | 2 | 1 | `alcohol` | Alcohol | 2 | | `bc3` | 3 | 2 | `crime_illegal` | Crime and illegal activity | 2 | | `bc4` | 4 | 3 | `death_tragedy_disaster` | Death, tragedy and disaster | 2 | | `bc5` | 5 | 4 | `firearms_weapons_violence` | Firearms, weapons and violence | 2 | | `bc6` | 6 | 5 | `gambling_betting` | Gambling and betting | 2 | | `bc7` | 7 | 6 | `mental_health_self_harm` | Mental health, self-harm and crisis | 2 | | `bc8` | 8 | 7 | `politics_elections` | Politics and elections | 2 | | `bc9` | 9 | 8 | `regulated_advice` | Regulated advice (medical, legal or financial) | 2 | | `bc10` | 10 | 9 | `tobacco_vaping_drugs` | Tobacco, vaping and recreational drugs | 2 | A set bit: the business does not want its published data used to build a response in that context. A suppression list, not a rating of the business. ## Use toggles (`use`) | Bit | Name | Since schema | |---|---|---| | 0 | `cite_as_source` | 1 | | 1 | `definitive_source` | 1 | | 2 | `prefer_over_inference` | 1 | | 3 | `include_in_recommendations` | 1 | | 4 | `quote_policy_verbatim` | 1 | | 5 | `prices_indicative` | 1 | | 6 | `state_publish_date` | 1 | ## Action terms (`action`) | Bit | Name | Since schema | |---|---|---| | 0 | `answer` | 1 | | 1 | `quote` | 1 | | 2 | `reserve` | 1 | | 3 | `purchase` | 1 | | 4 | `contact` | 1 | | 5 | `hand_to_human` | 1 | The `purchase` bit is withheld on rows while the business's authorised endpoints are suspended; the sealed record a fetch returns still says what the business sealed. ## Schemas | `ai_policy_schema` | Adds | |---|---| | 1 | the seven use toggles and six action terms (sealed as `terms_schema` 1 in the earlier `masterdb/terms/1` form) | | 2 | the ten blocked contexts, `bc1`–`bc10` | ## Example ```json { "ai_policy_version": 4, "ai_policy_schema": 2, "use": 79, "action": 51, "blocked": 130 } ``` `use` 79 = bits 0, 1, 2, 3, 6 (cite as source, definitive source, prefer over inference, include in recommendations, state publish date); `action` 51 = bits 0, 1, 4, 5 (answer, quote, contact, hand to human); `blocked` 130 = bits 1 and 7: **`bc2` alcohol and `bc8` politics_elections**. --- # The source line > Specification, version 1: the one line of provenance an AI cites beside an answer built from a MasterDB record, and what the public verify page answers for it. Source: https://docs.masterdb.ai/reference/source-line/ The **source line** is the one line of provenance an AI cites beside an answer it built from a MasterDB record, so the person reading the answer — or anyone they show it to — can check it without an account: who published the record, that it is exactly what they published, and that it was the version being served when the AI read it. Every fetch carries a ready-made line in `provenance.source_line`; for a search row, compose it from the row and the response's [receipt](/ai-companies/receipts/). The verifier libraries format and read it (`formatSourceLine` / `parseSourceLine`). ## The two forms **What a person sees** — you render it; the wording is yours: > Source: Acme Fashion Ltd · Verified business · Updated 2 min ago · **Check** where *Check* links to the verify URL below, and the name comes from the row's `business_name` or the verify page's `business.name`. **What a machine carries** — the source line itself, one ASCII line: ``` mdb-source/1 record={record_id} origin={adl_origin} cert={cert_id} served={served_at} verify={verify_url} ``` | Member | Required | Where you get it | Meaning | |---|---|---|---| | `mdb-source/1` | yes | — | The version tag. A reader refuses any other tag rather than guessing. | | `record` | yes | a row's `record_id`; a fetch's `record_id` | The record id (`mdb_…`, `bf_…`, `evt_…`, `job_…`, `upd_…`). | | `origin` | yes | a row's `adl_origin`; a fetch's receipt row | `sha256:` of the record's exact bytes. Possession of the record, never a bare id, is what the public page takes. | | `cert` | no | a row's `adl_origin_cert`; the fetch sidecar's `cert_id` | The certificate the record's seal named. Omit it when you do not have it; the page reports the certificate either way. | | `served` | yes | the receipt's `served_at` | When MasterDB served the record to you (RFC 3339, UTC). | | `verify` | yes | computed from the four above | `https://verify.masterdb.ai/v1/verify/{record}?origin={origin}[&cert={cert}]&served_at={served}`, query values percent-encoded. Always last. | Members are separated by one space and appear in this order; `cert` may be absent. A reader re-derives `verify` from the other members and refuses a line whose URL does not match — its host may differ (the sandbox's is `https://sandbox.verify.masterdb.ai`) but not its path or query — so a line whose link was swapped is caught. ## What the verify page answers `GET /v1/verify/{record_id}?origin=…[&cert=…][&served_at=…]` on `verify.masterdb.ai` — HTML for a browser, JSON otherwise (`?format=html|json` overrides). The JSON is signed by MasterDB's statement key as its own payload type, DSSE `application/vnd.masterdb.verify-page.v1+json`; the verifier libraries check it with `verifyVerifyPage` / `verify_verify_page`. See the [Verification API](/reference/public/). | Field | Answer | |---|---| | `valid` | The business published a record with these exact bytes, and nothing the line says contradicts that (`cert` matches the certificate the seal named; `served` falls while this version was being served, with ten minutes' grace). | | `status` | `current` — the version served now; `superseded`, `withdrawn`, `deleted` — when it stopped; `pulled` — MasterDB took it out of serving for review. | | `business` | The business's uuid, legal name and certificate today (status, since when, statement). Nothing on how the business was verified. | | `version` | Its generation, `sealed_at`, `published_at`, `ended_at` and the certificate its seal named. | | `checks` | `cert`: `matches` / `differs` / `not_given`; `served_at`: `within` / `outside` / `not_given`. | | `endpoints` | For a Business & Brand file served now: its authorised-endpoints section as MasterDB stands behind it today — `live`, or `suspended` with when and why — and when control of each domain was last confirmed. | | `source_line` | The line as MasterDB writes it for these facts, when `served_at` was given. | An unknown id and a hash that does not match answer the same `404`, so the page is no oracle for which records exist. The page does not re-check the seal: [`POST /v1/verify`](/ai-companies/verify/) with the record's bytes and its seal does that. ## What it does not claim The line proves where a record came from and when it was served. It cannot prove your answer was faithful to the record — that is why the line points at the record, so a reader can compare. A superseded record's line still verifies as *valid then*; to say "current", fetch again. How fresh the business expects a record of its type to be is on the row as `freshness_expectation` (see [Search](/ai-companies/search/)). ## Versioning A change to the members, their order or the URL is `mdb-source/2`, never an edit to version 1: lines already cited keep verifying. --- # Key custody > MasterDB's signed statement of who holds each business key — hosted (MasterDB holds it for the business) or self (the business holds it) — published beside the ADL Certificate, and how the verifier libraries check it. Source: https://docs.masterdb.ai/reference/key-custody/ A seal proves that a key signed a record. **Key custody** says whose hand was on that key. Every key on a business's register has a statement, signed by MasterDB, saying one of two things: | Custody | Who holds the private half | What a seal by the key proves | |---|---|---| | `hosted` | MasterDB, for the business. This is the key behind standard publishing: a person of the business signs in with an email link and confirms each publish with a one-time code sent to their verified address. | MasterDB signed, on a request a person with a grant on the business confirmed. Each use of the key is in the public transparency log, with a hash of the session that used it. | | `self` | The business: a person's passkey, or an integration key held by the business's own system. | A key the business holds signed it. | A key with no statement is reported **`unstated`**. A verifier never assumes `self`. ## Where it is published `GET /v1/certificates/{uuid}/key-custody` on `verify.masterdb.ai` (and `api.masterdb.ai`), beside the [ADL Certificate](/reference/public/) and the business's public keys (`GET /v1/certificates/{uuid}/keys`). It is public, needs no account, and lists every statement ever issued for the business, newest first: ```json { "uuid": "0b7d6c3e-2f4a-4c1b-9d8e-7f6a5b4c3d2e", "statements": [ { "key_id": "7Hs2pQ9vLm4xZk1Nt8Rc5Yw3Bf6Ju0Ea2Di7Go9Ks1M", "custody": "hosted", "effective_from": "2026-10-03T09:00:00.000Z", "issued_at": "2026-10-03T09:00:00.000Z", "statement_id": "sha256:…", "statement": { "payloadType": "application/vnd.masterdb.key-custody.v1+json", "payload": "…", "signatures": ["…", "…"] } } ] } ``` Only `statement` counts. The other members repeat what it says, so a person can read the answer without decoding it. ## The signed statement `statement` is a DSSE envelope of type `application/vnd.masterdb.key-custody.v1+json` over this payload, in RFC 8785 form: | Member | Meaning | |---|---| | `v` | `1`, the format. Any other value is refused (`version_unknown`). | | `business_uuid` | The business the key belongs to. | | `key_id` | The key's RFC 7638 thumbprint, as the seal and the published keys name it. | | `custody` | `hosted` or `self`. Anything else is refused (`payload_malformed`). | | `issued_at` | When MasterDB issued the statement. | | `effective_from` | From when it holds. A first statement takes effect from the key's own start; it is never later than `issued_at`. | | `sandbox` | `true` on a sandbox statement only, trusted only under the sandbox anchors. | No other member is allowed. The statement names no person. It is signed the way the ADL Certificate is signed: by **MasterDB's issuance key**, with both halves of it, the P-256 signature and the ML-DSA-65 signature, each by an issuance key valid at `issued_at` in the [signed key set](/reference/public/). Both are required. A statement carrying only one is refused with `post_quantum_required`. The issuance key, not a working key, signs custody because custody changes what a seal proves, so it carries the certificate's own trust. ## When a statement is issued - **A hosted key:** when MasterDB issues it, before it is ever used. No hosted seal exists without a `hosted` statement before it. - **Every other key:** when the certificate is issued or re-issued, and when the key first seals, if it has no statement yet. - **A change:** if a key's custody changes, a new statement is issued with a new `issued_at` and `effective_from`. The old one is never edited and stays listed. The statement in force at an instant is the one with the latest `effective_from` at or before it; a tie goes to the later issuance. Every issuance is a `key_event` leaf in the transparency log, over the SHA-256 of the envelope. ## Checking it with the verifier libraries | TypeScript / Python | What it does | |---|---| | `verifyKeyCustody` / `verify_key_custody` | One statement: the payload type, the format and its exact members, both issuance signatures, and `sandbox` against the anchors. With `expectUuid` / `expect_uuid`, a statement about another business is refused (`key_unknown`). | | `verifyKeyCustodyStatements` / `verify_key_custody_statements` | The whole answer of the route (or a list of envelopes): every statement checked, all about one business. | | `keyCustodyInForce` / `key_custody_in_force` | The statement in force for a key at an instant. | | `verifySeal` / `verify_seal` with `keyCustody` / `key_custody` | The seal as usual, and `custody` in the result: `hosted`, `self` or `unstated` for the seal's key at `sealed_at`. | ```ts const keySet = await productionKeySet(); const base = `https://verify.masterdb.ai/v1/certificates/${uuid}`; const [certificate, keys, keyCustody] = await Promise.all([base, `${base}/keys`, `${base}/key-custody`].map(async (u) => (await fetch(u)).json())); const result = verifySeal(recordBytes, seal, { publishedKeys: keys, keySet, certificates: [certificate.certificate], keyCustody }); console.log(result.custody); // 'hosted', 'self' or 'unstated' ``` ```python from masterdb_verifier import SealContext, production_key_set, verify_seal result = verify_seal(record_bytes, seal, SealContext(published_keys=keys, key_set=production_key_set(), certificates=[certificate["certificate"]], key_custody=key_custody)) print(result.custody) # "hosted", "self" or "unstated" ``` The cases both libraries are held to, accepted and refused, are `key-custody.json` in the [test vectors](/reference/test-vectors/). ## Why it is not in the certificate Version 1 of the ADL Certificate admits no member it does not name, and the verifier libraries refuse one they do not know, so custody is published beside the certificate rather than inside it. --- # Error codes > Every stable error code MasterDB answers with, its HTTP status and what it means. Generated from the code the services use. Source: https://docs.masterdb.ai/reference/errors/ Every refusal is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem document (`application/problem+json`) with a stable `code`. **Branch on `code`, never on `title` or `detail`**: a code, once published, is never renamed or reused; the titles are human text and may be reworded. The HTTP status is the one the code is answered with as a refusal. A few codes also appear as a `reason` inside a `200` body (the ad render and click confirmations answer `{accepted: false, reason}`). Refusals are never billed. See [Rate limits and errors](/ai-companies/rate-limits-and-errors/) for what to do with each class. ## Retrieval requests | Code | Status | Meaning | |---|---|---| | `sort_required` | 400 | A sort is required | | `filter_required` | 400 | A filter naming exactly one country is required | | `country_required` | 400 | A country is required | | `unknown_field` | 400 | Field not allowed for this collection | | `operator_not_allowed` | 400 | Operator not allowed for this field | | `sort_not_allowed` | 400 | Sort key not allowed for this collection | | `value_invalid` | 400 | Value is not valid for this field | | `limit_exceeded` | 400 | Limit exceeds the maximum | | `collection_unknown` | 400 | Unknown collection | | `request_invalid` | 400 | Request is not valid | ## Request signatures | Code | Status | Meaning | |---|---|---| | `signature_missing` | 401 | Request is not signed | | `signature_invalid` | 401 | Request signature is not valid | | `signature_expired` | 401 | Request signature is outside its validity window | | `key_unknown` | 401 | Signing key is not registered or not live | | `nonce_reused` | 401 | Nonce has already been used | | `digest_mismatch` | 400 | Content-Digest does not match the body | ## Identity, roles and passkeys | Code | Status | Meaning | |---|---|---| | `no_grant` | 403 | You hold no grant on this party | | `permission_denied` | 403 | Your roles on this party do not include this action | | `grant_expired` | 403 | Your access to this party has expired | | `grant_deactivated` | 403 | Your access to this party is deactivated | | `party_not_verified` | 403 | The party must be verified for this action | | `party_suspended` | 403 | The party is suspended | | `party_held` | 403 | The party is on hold: its records stay live, new publishing is paused | | `party_not_funded` | 402 | The party has no funds for this action | | `admin_hold` | 403 | A new admin cannot change grants, keys or mandates for 24 hours | | `passkey_required` | 403 | This action requires a passkey | | `device_bound_required` | 403 | This party requires a device-bound passkey for sealing | | `invitation_invalid` | 410 | Invitation is not valid | | `invitation_email_mismatch` | 403 | Sign in with the email address the invitation was sent to | | `domain_claimed` | 409 | This email domain belongs to an existing party | | `assembly_pending` | 409 | This AI company's setup is not complete | | `agreement_required` | 403 | The AI-company Terms in force must be accepted first | | `challenge_invalid` | 400 | Challenge is unknown, expired or already used | | `registration_invalid` | 400 | Passkey registration is not valid | | `assertion_invalid` | 401 | Passkey assertion is not valid | | `passkey_test_failed` | 400 | The new passkey failed its test signature and was not saved | | `credential_unknown` | 401 | Passkey is not registered or has been revoked | | `credential_exists` | 409 | Passkey is already registered | | `mint_assertion_missing` | 403 | Sign-in token has no recent mint assertion | | `session_invalid` | 401 | The session is not bound to a live MasterDB sign-in; sign in again | ## Records and seals | Code | Status | Meaning | |---|---|---| | `record_invalid` | 422 | Record is not valid | | `json_invalid` | 400 | Body is not one strict JSON value | | `record_too_large` | 413 | Record is too large | | `duplicate_key` | 422 | Object has a duplicate key | | `depth_exceeded` | 422 | Nesting is too deep | | `string_too_long` | 422 | String is too long | | `too_many_keys` | 422 | Object has too many keys | | `control_character` | 422 | String contains a control character | | `zero_width_character` | 422 | String contains a zero-width space or U+FEFF | | `bidi_override` | 422 | String contains a bidirectional override | | `not_nfc` | 422 | String is not in Unicode Normalization Form C | | `invalid_utf8` | 400 | Body is not valid UTF-8 | | `bom_present` | 400 | Body starts with a byte-order mark | | `number_invalid` | 422 | Number is outside the range every parser agrees on | | `money_not_string` | 422 | Money must be a decimal string | | `money_invalid` | 422 | Money string is not a valid decimal | | `schema_missing` | 422 | Record has no top-level schema field | | `schema_invalid` | 422 | Record schema field is not valid | | `not_canonical` | 422 | Bytes are not in the canonical form | | `seal_invalid` | 422 | Seal does not verify | | `seal_key_unknown` | 422 | Seal names a key that is not registered | | `seal_payload_type` | 422 | Envelope carries the wrong payload type | | `seal_hash_mismatch` | 422 | Seal hash does not match the record bytes | | `seal_time_skew` | 422 | sealed_at is too far from the time of receipt | | `seq_not_increasing` | 409 | Batch sequence number is not greater than the last accepted | | `price_country_not_published` | 422 | A price names a country the record is not published in | | `unsafe_url` | 422 | URL points at a private or unsafe address | | `field_required` | 422 | A required field is missing | | `plain_text_required` | 422 | Text must be plain text | | `role_address_required` | 422 | A published contact must be a role address, not a person | | `personal_data` | 422 | Freeform text must not carry personal data | | `country_invalid` | 422 | Not a country code in the platform vocabulary | | `currency_invalid` | 422 | Not a currency code in the platform vocabulary | | `language_invalid` | 422 | Not a language in the platform vocabulary | | `vocabulary_invalid` | 422 | Value is not in the controlled vocabulary | | `seal_required` | 422 | A seal is required | | `mandate_required` | 403 | The signing key has no live publishing mandate | | `mandate_scope` | 403 | The mandate does not cover this record type or country | | `draft_revision_conflict` | 409 | The draft has changed since you read it | | `scope_violation` | 422 | Text names another company or brand, or directs how other sources are treated | | `url_flagged` | 422 | URL is flagged as unsafe | | `display_domain_mismatch` | 422 | display_domain is not the destination host | | `image_type_refused` | 422 | Only JPEG, PNG and WebP images are accepted | | `image_invalid` | 422 | Image could not be decoded within the limits | | `source_not_allowed` | 403 | The request comes from outside the mandate’s source allow-list | | `domain_unproven` | 422 | An endpoint domain has no live proof of control | | `verification_level_insufficient` | 403 | This action is not available to this party until its verification is complete: finish it, then try again | | `screening_not_passed` | 403 | A check this action needs has not passed: try again later, and if it still has not passed, get in touch with us | ## Portal | Code | Status | Meaning | |---|---|---| | `verification_locked` | 409 | Locked while verification is submitted or decided | | `feature_not_enabled` | 403 | This feature is not enabled | | `prf_unsupported` | 422 | The passkey does not support the PRF extension | ## Ads and money | Code | Status | Meaning | |---|---|---| | `budget_exhausted` | 409 | Budget exhausted | | `window_expired` | 409 | Confirmation window has expired | | `token_invalid` | 400 | Token is not valid | | `token_reused` | 409 | Token has already been confirmed | | `allowance_exhausted` | 402 | Query allowance exhausted | ## API conventions | Code | Status | Meaning | |---|---|---| | `idempotency_key_missing` | 400 | Idempotency-Key header is required | | `idempotency_key_invalid` | 400 | Idempotency-Key header is not valid | | `idempotency_key_reused` | 422 | Idempotency-Key was used with a different request | | `idempotency_in_progress` | 409 | A request with this Idempotency-Key is in progress | | `unauthenticated` | 401 | Not signed in | | `step_up_required` | 403 | A stronger sign-in is required for this action | | `forbidden` | 403 | Not permitted | | `not_found` | 404 | Not found | | `conflict` | 409 | Conflict | | `rate_limited` | 429 | Too many requests | | `internal` | 500 | Internal error | | `not_implemented` | 501 | Not implemented | | `unavailable` | 503 | Temporarily unavailable | --- # Test vectors > The test vectors and the broken-record corpus every MasterDB verifier is held to — good artefacts to accept, broken ones to refuse for a named reason. Download them and hold your own verifier to them. Source: https://docs.masterdb.ai/reference/test-vectors/ Both [verifier libraries](/ai-companies/verifier-libraries/) are held to the same files: good cases every verifier must **accept**, and a corpus of deliberately broken artefacts every verifier must **refuse, for the reason named**. If you write your own verifier, hold it to them too. All keys in them are test keys derived from public seeds; the anchors they trust are in `context.json`. | File | Cases | What it holds | |---|---|---| | [`context.json`](/test-vectors/context.json) | — | The shared context of every vector: trust anchors, MasterDB key sets, and one fictional business (key register, certificate history, AI policy history, mandates). All keys are TEST keys derived from public seeds. | | [`keysets.json`](/test-vectors/keysets.json) | 3 | Signed key sets that every verifier must ACCEPT from the named anchors. | | [`certificates.json`](/test-vectors/certificates.json) | 8 | ADL Certificates that every verifier must ACCEPT against the named key set. | | [`seals.json`](/test-vectors/seals.json) | 17 | Records and their seals that every verifier must ACCEPT, with the context of context.json (business keys, certificates, AI policy history, mandates); a case whose input carries `certificates` is checked against that certificate history instead of the context’s. Seal format 2 (seal.v2, batch-seal.v2: ai_policy_version) and format 1 (v1: terms_version) both verify. | | [`ai-policy.json`](/test-vectors/ai-policy.json) | 4 | The ai_policy_bits a search row carries, each with the exact bytes of the sealed AI policy record a fetch returns (record_base64; null at version 0) and the fetch's ai_policy.version: every verifier must ACCEPT these — the bits derived from the sealed booleans, blocked contexts bc1 to bc10 at bits 0 to 9, the purchase bit alone allowed to be withheld. | | [`rows.json`](/test-vectors/rows.json) | 5 | Served index rows that every verifier must ACCEPT against the production key set. | | [`receipts.json`](/test-vectors/receipts.json) | 6 | Receipts that every verifier must ACCEPT against the production key set: format 3 (ai_policy_version rows), format 2 and format 1. | | [`mandates.json`](/test-vectors/mandates.json) | 2 | Mandates that every verifier must ACCEPT, sealed by the business's passkeys. | | [`merkle.json`](/test-vectors/merkle.json) | 7 | RFC 6962 inclusion proofs that every verifier must ACCEPT. | | [`log.json`](/test-vectors/log.json) | 4 | Transparency-log checkpoints, inclusion proofs (a seal leaf; a receipt, two-level) and a consistency proof that every verifier must ACCEPT, made by MasterDB’s log with the log_checkpoint key of the production key set. | | [`statements.json`](/test-vectors/statements.json) | 2 | MasterDB's public statements that every verifier must ACCEPT against the production key set, each under its own payload type: the verify page's signed JSON (kind verify_page, verify-page.v1) and the key directory (kind key_directory, key-directory.v1). | | [`projections.json`](/test-vectors/projections.json) | — | Every projection version (adl_proj hash) a verifier of this release knows, with the fields its row signature leaves out. | | [`key-custody.json`](/test-vectors/key-custody.json) | 17 | Key custody statements (key-custody.v1): who holds each business key, hosted (MasterDB holds it for the business) or self, signed by the issuance key pair (P-256 and ML-DSA-65, both required) and published beside the certificate (`GET /v1/certificates/{uuid}/key-custody`). Kind key_custody checks one statement against the named key set; kind key_custody_seal checks a seal of the business of context.json with its key_custody (the route document or a list of envelopes) and names the custody of the seal key at sealed_at (unstated when no statement names it). expect.valid says whether each must be accepted or REFUSED for the reason named. | | [`broken.json`](/test-vectors/broken.json) | 106 | The broken-record corpus: every input here must be REFUSED, for the reason named (the reason codes are shared by every MasterDB verifier). | ## The broken-record corpus Every one of these 106 inputs must be refused with the reason in the last column. | Case | Kind | What is wrong | Reason | |---|---|---|---| | `broken/seal/hosted-key-without-grant` | seal | A hosted-key seal whose sidecar's resolved grant does not include publish.products. | `out_of_scope` | | `broken/seal/hosted-key-checked-against-mandates` | seal | The same hosted-key seal judged as an integration key would be, against mandates: none covers it. | `out_of_scope` | | `broken/seal/published-keys-by-sidecar-key` | seal | A published key list signed by a MasterDB key that is not the statement key. | `key_unknown` | | `broken/seal/published-keys-edited` | seal | A published key list with a key removed after signing. | `signature_invalid` | | `broken/seal/published-keys-another-business` | seal | Validly published keys of another business, offered for this business's certificate. | `key_unknown` | | `broken/seal/byte-flipped` | seal | One bit of the record flipped. | `hash_mismatch` | | `broken/seal/re-serialised` | seal | The record parsed and re-serialised (same values, different bytes): a seal is over bytes, never over a re-serialisation. | `hash_mismatch` | | `broken/seal/batch-member-re-serialised` | seal | A Path A record re-serialised: its leaf no longer proves into the sealed root. | `inclusion_invalid` | | `broken/seal/key-swapped-in-payload` | seal | The payload's key_id swapped for another registered key; the signature is over the original payload. | `signature_invalid` | | `broken/seal/key-swapped-signer` | seal | Signed (validly) by one registered passkey while the payload names another. | `key_mismatch` | | `broken/seal/key-swapped-keyid` | seal | The signature entry relabelled with another registered passkey's key id. | `signature_invalid` | | `broken/seal/unregistered-key` | seal | Sealed by a key that is not in the business's register. | `key_unknown` | | `broken/seal/payload-type` | seal | A seal envelope relabelled as a receipt. | `payload_type_mismatch` | | `broken/seal/unknown-member` | seal | An envelope member DSSE does not define. | `envelope_malformed` | | `broken/seal/unknown-seal-version` | seal | A seal payload of v 3, validly signed: a verifier refuses a version it does not know rather than guessing. | `version_unknown` | | `broken/seal/format-2-payload-under-v1-type` | seal | A v 2 payload (ai_policy_version) signed under the format 1 payload type (seal.v1): the payload and its type disagree. | `payload_malformed` | | `broken/seal/format-1-member-under-v2` | seal | A seal.v2 payload of v 2 that still names terms_version: format 2 names the version ai_policy_version. | `payload_malformed` | | `broken/seal/unknown-ai-policy-schema` | seal | A validly sealed AI policy record at an ai_policy_schema this verifier does not know (3). | `version_unknown` | | `broken/seal/unknown-record-schema` | seal | A validly sealed record whose schema version is unknown (masterdb/products/9). | `version_unknown` | | `broken/seal/record-type-mismatch` | seal | A product record sealed as an event. | `record_type_mismatch` | | `broken/seal/duplicate-payload-key` | seal | A validly signed seal payload with a duplicated member (parsers disagree on which wins, so it is never read). | `payload_malformed` | | `broken/seal/expired-certificate` | seal | Sealed on 12 October naming the certificate, after the revocation that took effect on 10 October: not in force at sealed_at. | `certificate_not_in_force` | | `broken/seal/names-revoked-certificate` | seal | A seal naming the revoked issuance itself. | `certificate_not_in_force` | | `broken/seal/after-withdrawal` | seal | Sealed on 12 October naming the certificate, after MasterDB withdrew it (a reversed approval) with effect from 10 October: refused as withdrawn, not as a compromise. | `certificate_withdrawn` | | `broken/seal/names-withdrawn-certificate` | seal | A seal naming the withdrawn issuance itself. | `certificate_withdrawn` | | `broken/seal/unknown-certificate` | seal | A seal naming a certificate that was never issued. | `certificate_unknown` | | `broken/seal/wrong-ai-policy-version` | seal | Sealed on 1 October binding AI policy version 2, which went live on 5 October. | `ai_policy_version_mismatch` | | `broken/seal/format-1-wrong-terms-version` | seal | A format 1 seal binding terms_version 2 on 1 October, before version 2 went live: refused for the same reason. | `ai_policy_version_mismatch` | | `broken/seal/ai-policy-record-wrong-version` | seal | An AI policy record's seal naming a version it cannot create (5). | `ai_policy_version_mismatch` | | `broken/seal/out-of-scope-country` | seal | A pushed record for France under a mandate for the US and Ireland only. | `out_of_scope` | | `broken/seal/out-of-scope-type` | seal | An integration key sealing an events document under a products-only mandate. | `out_of_scope` | | `broken/seal/out-of-scope-mandate-expired` | seal | Sealed on 9 October, after the key's mandate ended on 8 October. | `out_of_scope` | | `broken/seal/sidecar-format-mismatch` | seal | A v 2 sidecar signed under the format 1 sidecar type (sidecar.v1). | `payload_malformed` | | `broken/seal/out-of-scope-grant` | seal | Path B: the sidecar says the person's grant at acceptance did not include publish.products. | `out_of_scope` | | `broken/seal/revoked-key` | seal | Sealed on 30 September with a passkey revoked with effect from 25 September. | `key_not_valid` | | `broken/seal/webauthn-wrong-origin` | seal | A passkey assertion made on an origin outside the allow-list. | `signature_invalid` | | `broken/seal/webauthn-sign-in-challenge` | seal | A sign-in assertion ("mdb-signin" \|\| nonce) replayed as a seal. | `signature_invalid` | | `broken/seal/webauthn-no-user-verification` | seal | A passkey assertion without the user-verification flag. | `signature_invalid` | | `broken/seal/webauthn-rs256-signature-flipped` | seal | An RS256 passkey seal with one bit of its RSA signature flipped. | `signature_invalid` | | `broken/seal/webauthn-rs256-relabelled-es256` | seal | An RS256 passkey assertion relabelled with an ES256 passkey's key id (and the payload's key_id left as the RS256 key's). | `signature_invalid` | | `broken/seal/batch-proof-tampered` | seal | A Path A record whose inclusion proof was altered. | `inclusion_invalid` | | `broken/seal/batch-wrong-leaf-index` | seal | A Path A record presented at another leaf index. | `inclusion_invalid` | | `broken/ai-policy/blocked-bit-cleared` | ai_policy | Served bits with a blocked context the business set (bc8 politics_elections) cleared. | `ai_policy_bits_mismatch` | | `broken/ai-policy/blocked-bit-added` | ai_policy | Served bits with a blocked context the business never set (bc1). | `ai_policy_bits_mismatch` | | `broken/ai-policy/action-widened` | ai_policy | Served bits granting reserve (action bit 2), which the sealed record does not. | `ai_policy_bits_mismatch` | | `broken/ai-policy/wrong-version` | ai_policy | Bits naming a version other than the fetched record's. | `ai_policy_bits_mismatch` | | `broken/ai-policy/wrong-schema` | ai_policy | Bits claiming ai_policy_schema 1 for a schema 2 record (so the blocked contexts would be read as absent). | `ai_policy_bits_mismatch` | | `broken/ai-policy/version-0-with-bits` | ai_policy | No sealed AI policy (version 0), yet bits that permit answering. | `ai_policy_bits_mismatch` | | `broken/mandate/scope-widened` | mandate | The mandate's countries widened after it was sealed. | `signature_invalid` | | `broken/mandate/sealed-by-integration-key` | mandate | A mandate signed by the machine key itself: a mandate is a person's act, sealed by a passkey. | `key_unknown` | | `broken/entitlement/expired` | entitlement | The same entitlement checked twenty minutes after it was issued: it lapsed at expires. | `entitlement_expired` | | `broken/entitlement/other-vendor` | entitlement | An entitlement presented to another AI company. | `entitlement_invalid` | | `broken/entitlement/other-listing` | entitlement | An entitlement for another of the vendor's listings. | `entitlement_invalid` | | `broken/entitlement/seat-changed` | entitlement | The seat changed after signing. | `signature_invalid` | | `broken/entitlement/signed-by-statement-key` | entitlement | An entitlement signed by the verify statement key, which may not issue entitlements. | `key_unknown` | | `broken/entitlement/extra-member` | entitlement | A validly signed entitlement carrying a member the format does not have. | `payload_malformed` | | `broken/statement/key-directory-as-verify-page` | verify_page | A genuine key directory offered where a verify page is expected. | `payload_type_mismatch` | | `broken/statement/verify-page-as-key-directory` | key_directory | A genuine verify page offered where the key directory is expected. | `payload_type_mismatch` | | `broken/statement/verify-page-as-verify-statement` | verify_page | The verify page's answer in its first form: a verify-statement.v1 (the type of POST /v1/verify's answer) told apart by a kind member. | `payload_type_mismatch` | | `broken/statement/key-directory-as-verify-statement` | key_directory | The key directory in its first form: a verify-statement.v1 told apart by a kind member. | `payload_type_mismatch` | | `broken/statement/verify-page-kind-member` | verify_page | A validly signed verify-page.v1 still carrying the kind member the format no longer has. | `payload_malformed` | | `broken/statement/verify-page-verdict-changed` | verify_page | A verify page whose signed verdict was flipped from valid to not valid after signing. | `signature_invalid` | | `broken/statement/key-directory-by-sidecar-key` | key_directory | A key directory signed by a MasterDB key that is not the statement key. | `key_unknown` | | `broken/certificate/tampered` | certificate | The legal name changed after signing. | `signature_invalid` | | `broken/certificate/signed-by-working-key` | certificate | A certificate signed by the sidecar key, which may not issue certificates. | `key_unknown` | | `broken/certificate/sandbox-under-production` | certificate | A sandbox certificate checked against the production anchors. | `key_unknown` | | `broken/certificate/reason-on-active` | certificate | A validly signed active certificate carrying a status_reason, which only a withdrawn issuance has. | `payload_malformed` | | `broken/certificate/unknown-member` | certificate | The format is closed. A validly signed certificate with a member that is not in the format (here extra_member) is refused; a new member is a new format. | `payload_malformed` | | `broken/certificate/unknown-version` | certificate | A validly signed certificate of format v 2. | `version_unknown` | | `broken/certificate/classical-only` | certificate | An ADL Certificate is hybrid-signed (P-256 and ML-DSA-65, both required): one carrying only the P-256 signature is refused. | `post_quantum_required` | | `broken/certificate/ml-dsa-only` | certificate | A certificate carrying only the ML-DSA-65 signature of the issuance key is refused, however valid that signature. | `post_quantum_required` | | `broken/certificate/two-classical-signatures` | certificate | Two signatures, but not the two halves: a second signature by a root key (which does not issue certificates) is ignored, so the certificate still lacks its ML-DSA-65 half. | `post_quantum_required` | | `broken/keyset/wrong-anchors` | keyset | The production key set checked against anchors that are not MasterDB's. | `trust_chain_broken` | | `broken/keyset/compromise-mark-removed` | keyset | The compromised_from mark removed from the signed payload. | `signature_invalid` | | `broken/keyset/key-without-certificate` | keyset | A validly signed set listing a key with no certificate chaining to the anchors. | `trust_chain_broken` | | `broken/keyset/classical-only` | keyset | The key set is hybrid-signed: one signed by the P-256 issuance key alone is refused. | `post_quantum_required` | | `broken/keyset/ml-dsa-only` | keyset | A key set signed by the ML-DSA-65 issuance half alone (all else genuine) is refused. | `post_quantum_required` | | `broken/keyset/key-certified-by-ml-dsa-only` | keyset | A set listing a receipt key whose key certificate carries only the ML-DSA-65 issuance signature. The key is not trusted; the whole set is refused. | `post_quantum_required` | | `broken/keyset/key-certified-by-classical-only` | keyset | A set listing a receipt key whose key certificate carries only the P-256 issuance signature: refused. | `post_quantum_required` | | `broken/keyset/successor-root-certified-by-classical-only` | keyset | A successor root certified by the old root P-256 key alone: it is not trusted, and nothing certified under it is. | `post_quantum_required` | | `broken/row/v3-ai-policy-version-changed` | row | A v3 row whose signed ai_policy_version was changed. | `row_signature_invalid` | | `broken/row/v2-price-changed` | row | A v2 row whose US price changed. | `row_signature_invalid` | | `broken/row/v2-under-v1-rules` | row | A v2 row relabelled as a v1 row: the adl_proj is part of the signed bytes, so the signature no longer verifies. | `row_signature_invalid` | | `broken/row/price-changed` | row | The US price on a served row changed. | `row_signature_invalid` | | `broken/row/unknown-projection` | row | A row naming a projection version nobody published. | `projection_unknown` | | `broken/row/signed-by-receipt-key` | row | A row signed by a MasterDB key that is not a projection key. | `key_unknown` | | `broken/receipt/row-changed` | receipt | The receipt's row origin changed after signing ("you served me the old price"). | `signature_invalid` | | `broken/receipt/expired-key` | receipt | Signed at 09:30 on 1 October by a receipt key whose window ended at midnight. | `key_not_valid` | | `broken/receipt/compromised-key` | receipt | Signed after the receipt key's compromised_from. | `key_compromised` | | `broken/receipt/signed-by-projection-key` | receipt | A receipt signed by a MasterDB key that is not a receipt key. | `key_unknown` | | `broken/receipt/v2-another-caller` | receipt | A leaked format 2 receipt presented by a caller it was not issued to. | `receipt_caller_mismatch` | | `broken/receipt/v1-when-caller-required` | receipt | A format 1 receipt where the checker requires the caller to be named (it names none). | `receipt_caller_mismatch` | | `broken/receipt/v2-without-caller` | receipt | Format 2 claimed without caller_key_id. | `payload_malformed` | | `broken/receipt/v3-terms-version-row` | receipt | Format 3 claimed with a row still naming terms_version. | `payload_malformed` | | `broken/receipt/v2-ai-policy-version-row` | receipt | Format 2 claimed with rows naming ai_policy_version, which only format 3 has. | `payload_malformed` | | `broken/receipt/v3-without-caller` | receipt | Format 3 claimed without caller_key_id. | `payload_malformed` | | `broken/receipt/other-region` | receipt | A receipt claiming one region, signed by another region’s receipt key. | `key_unknown` | | `broken/receipt/served-row-differs` | receipt | A genuine receipt, checked against a served row whose origin differs from what the receipt says was served. | `receipt_row_mismatch` | | `broken/merkle/wrong-tree-size` | merkle | A genuine proof checked against a tree size other than the one that was signed. | `inclusion_invalid` | | `broken/log/checkpoint-size-changed` | checkpoint | A checkpoint whose tree size was changed after signing. | `checkpoint_invalid` | | `broken/log/checkpoint-other-origin` | checkpoint | A note validly signed by the log key for another origin. | `checkpoint_invalid` | | `broken/log/checkpoint-unknown-key` | checkpoint | A checkpoint signed by a key named masterdb-log that is not the log key. | `checkpoint_invalid` | | `broken/log/inclusion-wrong-index` | log_inclusion | A genuine seal-leaf proof presented at another leaf index. | `inclusion_invalid` | | `broken/log/receipt-minute-size` | log_inclusion | A receipt proof whose minute-tree size was changed: the first level no longer verifies. | `inclusion_invalid` | | `broken/log/consistency-wrong-older` | log_consistency | A consistency proof checked against a different older checkpoint (the newer one itself as older). | `checkpoint_invalid` | | `broken/log/consistency-tampered` | log_consistency | A consistency proof with one hash replaced. | `consistency_invalid` | --- # Security model > What MasterDB guarantees, what it does not claim, the protections against each kind of attacker, and how anyone can check what MasterDB serves. Source: https://docs.masterdb.ai/threat-model/ This page states what MasterDB guarantees, and just as plainly what it does not. **Read "What MasterDB does not claim" as carefully as the guarantees.** Every guarantee here can be checked with public data and the open-source [verifier libraries](/ai-companies/verifier-libraries/). ## What MasterDB is, for this purpose Verified businesses publish records — products, Business & Brand files, events, jobs, updates, and their AI policy — each **sealed** over its exact bytes. AI companies retrieve them with **signed requests**, and MasterDB answers with index rows it has **signed**, records exactly as sealed, and a **signed receipt**. Certificates, keys, projection specifications and a **transparency log** are public, so everything MasterDB serves can be checked without trusting MasterDB. ## What MasterDB guarantees 1. **Nothing MasterDB delivers can carry instructions beyond the business that published it.** Every record is bound to its author by its seal, and publishing refuses text that names another company or brand the business does not own or sell, or that directs how other sources should be treated. A record can speak only for its own business. 2. **What MasterDB delivers can be checked without trusting MasterDB**: a record against its seal, a row against the published projection, a receipt against MasterDB's published keys, all chained to pinned trust anchors, with open-source verifiers and public test vectors. 3. **MasterDB cannot forge the seal of a business that holds its own key.** A business that seals with a person's passkey or its own integration key holds the only private key. A business that uses **hosted signing** asks MasterDB to sign for it: MasterDB signs only after a person at the business has confirmed the exact request. Such a seal proves that MasterDB signed on a request a person confirmed, not that the business alone could have produced it. MasterDB's signed [key custody statement](/reference/key-custody/) says, for every key, whether it is `hosted` or `self`, and a verifier can read it. 4. **MasterDB cannot forge an AI company's request.** It holds only the public half of every retrieval key; every billable request carries the company's own signature, and every receipt is bound to it. 5. **MasterDB cannot quietly rewrite what it asserted it served.** Receipts, seals, key events and certificate issuances are folded into an append-only transparency log. Anyone who keeps two checkpoints can ask for the consistency proof between them (`GET /v1/log/consistency`) and see that nothing was edited or removed. 6. **MasterDB does not rank.** A search has no default order; the caller names the sort. There is no ranking mechanism to turn. 7. **A blocked AI company is not told**, anywhere on MasterDB's authenticated surfaces (see the limits below). ## What MasterDB does not claim - **MasterDB does not stop prompt injection in general.** Binding every record to its author means text cannot reach beyond its author — and that is all. A business can still publish text that attempts to instruct an AI about *its own* products, and MasterDB cannot make an AI ignore it. Defending a model against the content it reads is the AI company's work. - **MasterDB does not see what an AI company does after retrieval**, and cannot prove an answer was faithful, or that data was not cached or reused. Those controls are contractual, not cryptographic. - **MasterDB cannot technically stop an AI company training a model on what it retrieved.** The **AI-company Terms**, which every AI company accepts before it receives a production key, forbid training on the data, caching it for reuse, and using it beyond one conversation. And **every delivery is attributable**: each request is signed by the company's own key, each response carries a receipt bound to that key, and the receipts are in the transparency log. A record, or a distinctive price or phrase from one, found in a model's output or a training set can be traced to the company it was served to and when. A breach is detectable and provable after the fact, and actionable under the Terms. - **MasterDB is not in the payment** and cannot say a purchase went right. - **A seal proves who published bytes and when; it does not prove the bytes are true.** - **Verification raises the cost of impersonation; it does not make impersonation impossible.** It is not an endorsement of what a business publishes. - **A signed message from an AI company proves which company sent it; it does not prove a person asked for it.** - **A receipt proves what MasterDB asserted it served; it does not prove the response arrived.** - **A block is hidden on MasterDB's surfaces — and only there.** MasterDB therefore offers no completeness proof. A company that compares results with another company, reads a business's own website, or presents a record it already holds to the public verify endpoint could infer a block. Blocking does not recall what was retrieved before it. - **Image-fetch counts are a lower bound on renders**, not an observation of every render. - **Deletion means deletion.** After a business deletes a record, MasterDB keeps its fingerprints — hashes, log leaves, receipts and billing rows — not its bytes. MasterDB can show *that* a record with a given hash was live at a given time and *to whom* it was served; only someone who kept the bytes can show *what* it said. - **Action terms and blocked contexts are honoured by the AI company.** A business's `purchase: false`, or a context it blocks, binds an AI company under the Terms; nothing cryptographic stops a system that ignores it. ## Protections, and the limit of each ### A dishonest business *Wants:* to publish a claim about another company, an instruction aimed at AIs, a false price or a false identity. | Protection | Limit | |---|---| | **Scope check at publish**: text naming a company or brand the business does not own or sell, or directing how other sources are treated, is refused. Names are matched after Unicode normalisation, case folding and folding of look-alike characters. Claiming another business's registered brand as one's own is refused. | Matching is on names: a paraphrase that names no one passes. | | **Plain text only**, **role addresses only** in any published contact, strict JSON, size and character limits, unsafe URLs refused, and every URL checked for known malware and phishing at publish and daily after. | A legitimate domain compromised later is caught by the daily re-check, not at once. | | **Every record sealed to a verified legal identity**: attributable, revocable, and its history stands. | A seal makes a false claim attributable; it does not make it true. | | **Verification** of the legal entity before it publishes. | See "What MasterDB does not claim". | ### A dishonest AI company *Wants:* to copy the corpus, under-report renders, over-report clicks, dispute its bill, or learn who blocked it. | Protection | Limit | |---|---| | **No bulk path**: no list, no export, no batch fetch, at most 50 rows a search and no second page; every search and fetch is billed. The public verify endpoint needs the record itself, never a bare id. | A company willing to pay can still walk a catalogue fifty rows at a time; that is bounded by cost, not prevented. | | **Every request signed by the company** (non-repudiable); every response carries a signed receipt; the bill is the receipts. | — | | **Renders observed by image fetch; clicks measured across companies.** | Fetch counts are a lower bound; fraud is detected by ratios, not proven per event. | | **Blocks are invisible by construction**: identical `404`s, no field, count or aggregate anywhere, no completeness proof. | See "What MasterDB does not claim". | | **The Terms and each business's AI policy** bind contractually, and the receipts give the evidence. | Enforcement is contractual, after the fact. | ### A third party with a stolen business credential *Wants:* to publish in the business's name. | Protection | Limit | |---|---| | **Passkeys cannot be phished** (bound to MasterDB's domain), and a device-bound passkey cannot be exported. A business can require device-bound passkeys. | A synced passkey is as safe as the account that syncs it. | | **An integration key needs a mandate**: sealed by a person's passkey, scoped to record types and countries, at most 92 days, with per-key caps and an optional source allow-list. **A per-key sequence number** stops a stolen key replaying an older seal to roll a price back. **The price-shock hold** stops a push that moves a fifth of a catalogue's prices until a person confirms it. | A thief with a live key and mandate can publish inside the mandate's scope until the key is revoked. | | **Revocation with an effective moment** invalidates what was sealed after it, and nothing before. | — | | **A confirmation email on a separate channel** (to every owner and admin, naming the person, each time a person publishes, withdraws or deletes a record in the portal) exposes misuse within minutes. A push through the API sends no email for each product; the business's developers are emailed at once only when a push needs a person. | Detection, not prevention. | | **An email-only session can do nothing sensitive**; editing a draft on a verified business needs a fresh strong sign-in; recovering a lost passkey is followed by a 72-hour cooling period before the new passkey can seal. | — | ### A third party with a stolen AI-company key *Wants:* to query at the victim's expense. | Protection | Limit | |---|---| | **Signed requests** with a five-minute window and single-use nonces cannot be replayed; each signed request is **billed once**. | A thief holding the private key can sign new requests until it is revoked. | | **Per-key rate limits and cost budgets**; **revocation fails closed** in every region within seconds. | — | | **The victim's receipts and its own reconciliation** expose use it did not make. Liability sits with the party whose key it was, as the Terms state. | — | ### A network attacker *Wants:* to alter data in transit, or replay it. | Protection | Limit | |---|---| | TLS 1.3 only, with a hybrid post-quantum key exchange; every request signed with its body's digest covered; every row and receipt signed; records verifiable against their seals. | — | ### A denial of service *Wants:* to take a region down, or drain a business's ad budget with fake demand. | Protection | Limit | |---|---| | Edge throttling and a web application firewall; regional failover; per-key limits; ad reserves released when their window expires; an ads pool is a signed request from a known key, so an unknown caller never reaches a budget. The public verification surface is never rate-limited so as to block a checker. | Availability is engineered, not guaranteed. | ### A compromised MasterDB service *Wants:* to forge seals, receipts or rows. | Protection | Limit | |---|---| | **MasterDB holds no AI-company private key**, so it cannot forge a request, and **no private key of a business that holds its own**, so it cannot forge that business's seal. | A hosted key is held by MasterDB, and its key custody statement says `hosted`. | | **Every use of a hosted key and every certificate issuance** is recorded in the transparency log and reconciled against MasterDB's own signing records. | Detection, not prevention. | | MasterDB's working keys (projection, receipt, sidecar, statement) are **rotated every 30 days**, certified by an issuance key, and can be marked compromised from a moment, which verifiers apply. | A forged row or receipt is possible within one working key's window, until detected. | | Every issuance, seal and minute of receipts is **in the transparency log**. | — | | **The root key** lives in a hardware security module and is used only in a recorded key ceremony. Its anchors are pinned in both verifier libraries. Every long-lived artefact also carries an ML-DSA-65 signature, and the verifier libraries require both. | — | ### A compromised portal build *Wants:* to make a person seal bytes they did not see. | Protection | Limit | |---|---| | A content security policy with subresource integrity on every script; the diff since the last publish on the save screen; the confirmation email to every owner and admin, with a link to the record in the portal. | Detection within minutes, not prevention. | ### The software supply chain *Wants:* a malicious dependency in a portal or a service. | Protection | Limit | |---|---| | Lockfiles with provenance, dependency review, a minimal dependency policy for the sealing path, and container images built from pinned lockfiles and admitted only with a build attestation. | A dependency compromised upstream before it is pinned is not caught by pinning. | ### A compromised MasterDB staff account *Wants:* to alter records, approve a fraud, or export data. | Protection | Limit | |---|---| | Staff tools behind multi-factor sign-in; **one endpoint per action**, no generic edit; **every action audited before it takes effect**, with alerting; no bulk export and no direct database access. | Detection, not prevention. | ### A regulator or a court *Wants:* evidence. | Protection | Limit | |---|---| | The same artefacts as everyone else — receipts, seals, certificates, the transparency log and evidence packs — with no special path. | After a deletion, only fingerprints remain. | ### MasterDB itself, in the future *Wants:* to rank quietly, or edit history quietly. | Protection | Limit | |---|---| | **There is no ranking mechanism** to turn a dial on; the projection specifications are public, versioned and re-runnable, so anyone can check a row against its record. **History cannot be edited without the log showing it.** | A future MasterDB could change the software; it could not change what was already logged without it showing. | ## Cryptography | Purpose | Algorithm | |---|---| | Business seals made with a passkey | ES256, or RS256 where the device makes only RSA keys — a WebAuthn assertion either way | | Business seals made with an integration key, AI-company request signatures, MasterDB's row, receipt and checkpoint signatures | Ed25519 | | Seals made with a hosted key | ES256; the key custody statement says `hosted` | | MasterDB's root and issuance keys | ECDSA P-256, held in a hardware security module, plus ML-DSA-65 | | Long-lived artefacts' second signature | ML-DSA-65 (the verifier libraries require both signatures of key certificates, ADL Certificates, key custody statements and the key set) | | Hashing | SHA-256 | | Envelopes | DSSE with a typed `payloadType` per artefact; the algorithm comes from the key register, never from the envelope | | Request signing | RFC 9421 with RFC 9530 `Content-Digest` | | TLS key exchange | hybrid X25519 + ML-KEM-768 | No JSON canonicalisation on the API path: MasterDB signs and stores the octets it received. The portal path uses a canonical form MasterDB defines at both ends. ## Reporting a security problem Use the contact form on [masterdb.ai/contact](https://masterdb.ai/contact) (see [Support](/support/)) and say it is a security report. Please give MasterDB a chance to fix a problem before you publish it, and test only in the [sandbox](/sandbox/), never against production businesses' data. --- # Changelog > The versions of MasterDB's public APIs and signed formats in force, and how changes an integration could notice are announced. Source: https://docs.masterdb.ai/changelog/ Every change an integration could notice — to an API, an error code, a signed format, a verifier library or the security model — is listed here, newest first. Within `/v1/` changes are additive only ([Deprecation policy](/deprecation-policy/)). ## Version 1 The retrieval, verification and business APIs, as documented in the [API reference](/reference/), with these signed formats: | Artefact | Format in force | Also verified | |---|---|---| | Business seal | `seal.v2` (names `ai_policy_version`) | `seal.v1` (names `terms_version`) | | Batch seal | `batch-seal.v2` | `batch-seal.v1` | | Receipt | `receipt.v3` (each row names `ai_policy_version`) | `receipt.v2`, `receipt.v1` | | Sidecar | `sidecar.v2` | `sidecar.v1` | | Projection specification | v3 | v1, v2 (their rows verify with `ai_policy_bits` removed first) | | ADL Certificate | `v` 1 | — | | Key custody statement | `key-custody.v1` | — | | Verify page | `verify-page.v1` | — | | Source line | `mdb-source/1` | — | | AI policy record | `masterdb/ai_policy/1`, `ai_policy_schema` 2 | `ai_policy_schema` 1, and the earlier `masterdb/terms/1` form | The verifier libraries accept every format in the table and refuse any version they do not know. --- # Deprecation policy > How MasterDB's APIs and signed formats change — additive within a version, twelve months' notice before anything is removed, and signed artefacts readable forever. Source: https://docs.masterdb.ai/deprecation-policy/ ## Within a version Every API carries its major version in the path (`/v1/`). Within a version, changes are **additive only**: - new endpoints; - new optional request members, and new values in lists the documentation says may grow; - new members in responses — **ignore members you do not know**; - new error codes. Nothing is renamed or removed, and no existing request starts being refused, within a version. ## Error codes are permanent A published error code is never renamed and never reused for a different meaning. Its human title may be reworded. ## Removing something Nothing is removed without **twelve months' notice**: a new version is published beside the old, the old one is marked deprecated in the reference and in this [changelog](/changelog/), and both are served until the notice ends. A security fix that cannot wait is the only exception, and it is announced here with its reason. ## Signed artefacts are forever A seal, a certificate, a receipt or a sealed record is never re-issued in a new shape. Every one carries its own version — `v`, `schema`, `ai_policy_schema`, `payloadType`, `adl_proj` — so a verifier written today can read what was signed today in 2035, and refuses a version it does not know instead of guessing. New formats are new versions beside the old; verifiers accept each version they know. Projection specifications are pinned by hash and stay published, so an old row can always be checked against the specification that made it. ## Libraries The verifier libraries and SDKs follow semantic versioning. A release that pins MasterDB's trust anchors, or adds a root successor, is a routine minor release: anchors are a set, so a root can rotate without a flag day. --- # Support > How to reach MasterDB's developer support — a question about an integration, a problem in the sandbox or these documents, or a security report. Source: https://docs.masterdb.ai/support/ **To reach developer support**, use the contact form on MasterDB's website: [masterdb.ai/contact](https://masterdb.ai/contact). Questions about an integration, problems in the sandbox or in these documents, and security reports all go there; say in your message which one it is (and that it is a security report, if it is one), and we reply to the address you give in the form. Please do not include keys, tokens or passwords: we never need them, and we will never ask for them. **What helps us answer quickly:** the `x-request-id` header of a response, a receipt's `retrieval_id`, a problem's `code`, and whether it happened in the sandbox or in production. **Security reports** are read first. Please give us a chance to fix a problem before you publish it, and test only in the [sandbox](/sandbox/), never against production businesses' data. The [security model](/threat-model/) says what MasterDB guarantees and what it does not claim.