Pushes and mandates
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
Section titled “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). Without it the first request is refused key_unknown.
2. The mandate
Section titled “2. The mandate”An owner or admin seals a mandate for the key with their passkey:
{ "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
429rate_limitedwithRetry-Afterbefore 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
403source_not_allowed. - Validity: at most 92 days. Renewing is one tap a quarter; revoking is immediate.
3. A push
Section titled “3. A push”// 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<typeof pushed.data>): 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`);"""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 jsonimport osimport timeimport uuidfrom datetime import datetime, timedelta, timezone
import httpxfrom 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.
seqmust 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_atmust be within five minutes of when MasterDB receives the batch.cert_idnames your certificate in force, andai_policy_versionyour AI policy in force: read both fromGET /v1/seal-context(signed withmdb-business-read, like every read). If you seal with a version that is no longer in force, the push is refusedseal_invalid, and the problem’sai_policy_versionmember 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), and carries anIdempotency-Key: a retried batch with the sameseqanswers 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): 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
Section titled “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
Section titled “Reading back”GET /v1/catalogue— the manifest of what you have published: ids, yourbusiness_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 (pendingwhile its push has not settled it).GET /v1/analytics/dailyand/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
Section titled “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.
// 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');"""Withdraw a record: a sealed document, never an HTTP verb on a bare id. The record of truth and itshistory stay; the record leaves serving in every region."""
import osimport uuid
import httpxfrom 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
Section titled “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.