Skip to content

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.

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.

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 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.
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<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`);

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), 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): 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.)

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.

  • 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.

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.

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');

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.

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.