Ma'atara Docs Developer Academic maatara.io →

Public API surface

This page records the public contract in the current source tree and the production hosts that serve it. Source changes can precede deployment, so verify /health and /api/capabilities before assuming a newly documented capability is live. Routes reserved for internal service-to-service traffic, administration, or unsupported compatibility shims are not listed unless their public migration response is itself part of the contract.

Source-contract review (21 September 2026). The hosts below are the intended public surfaces. A source change can precede its production deployment, so the current release still requires exact-build health and smoke receipts. Several data surfaces may be empty — GET /api/anchors can return {"items": [], "count": 0} and GET /api/epoch/current can return {"epoch": null, "message": "No active epoch"}. Where a capability is scaffolded rather than in service, this page says so.

Hosts

HostServiceScopeVersion label (verify live build separately)
api.ma-atara.iocore-workerChain, notes, inbox, inception, anchors, assets, governance, pairing, PQC verification1.5.2
aegis.ma-atara.ioaegis-workerProvenance registration, verification, similarity search, client assets0.2.3
consent.ma-atara.ioai-consent-workerAI-training consent discovery and lookup1.0.3
did.ma-atara.iodid-resolver-workerDID resolution and registration, org bindings0.1.0
keys.ma-atara.ioedge-key-fabricDID-native key directory, prekeys, guardian consensus, epochs, transparency0.1.5
auth.ma-atara.iomaatara-auth-workerHuman sign-in (Turnstile, OPAQUE, passkeys, OAuth onboarding)0.1.4
pay.ma-atara.iopayments-workerPricing, entitlements, quota0.1.3
docs.ma-atara.iodocs-workerThis documentation site1.0.1

Aliases: aegis.ma-atara.io is also served at provenance.ma-atara.io and api.maatara.io; consent.ma-atara.io is also served at consent.maatara.io. All five hostnames are live — the first three reach aegis-worker, the last two ai-consent-worker. Note the near-collision: api.maatara.io (unhyphenated) is aegis, not the core worker at api.ma-atara.io. Use the hyphenated ma-atara.io names in new integrations.

Deployment tooling injects build identity. Record the service and build reported by /health and /api/version; a value of unknown is missing evidence, not a deployment identifier. A version label alone does not distinguish different builds of the same service version.

Try it now

The read-only liveness and discovery commands below need no credentials or account. Mutation examples are intentionally omitted from this smoke list because some production mutations require an allow-listed Origin, a scoped assertion, or both. Placeholder lookups may answer with data or a 404; do not infer that a mutation is unauthenticated from a read response.

# Liveness across the fleet
curl https://api.ma-atara.io/health
curl https://aegis.ma-atara.io/health
curl https://consent.ma-atara.io/health
curl https://did.ma-atara.io/health
curl https://keys.ma-atara.io/health
curl https://auth.ma-atara.io/health
curl https://pay.ma-atara.io/health
curl https://docs.ma-atara.io/health

# Capability and version discovery
curl https://api.ma-atara.io/api/capabilities
curl https://api.ma-atara.io/api/version
curl https://api.ma-atara.io/openapi.json
curl https://aegis.ma-atara.io/api/capabilities
curl https://keys.ma-atara.io/api/capabilities

# DID resolution (W3C DID Core / Universal Resolver shaped)
curl https://did.ma-atara.io/.well-known/did.json
curl -H 'Accept: application/did-resolution' "https://did.ma-atara.io/1.0/identifiers/did:maatara:<fingerprint>"
curl "https://did.ma-atara.io/1.0/org/<orgDid>/officers"
curl "https://did.ma-atara.io/1.0/org/<orgDid>/verify/<memberDid>"

# AI-training consent (crawler-facing)
curl https://consent.ma-atara.io/robots.txt
curl https://consent.ma-atara.io/.well-known/ai-consent
curl https://consent.ma-atara.io/.well-known/tdmrep.json
curl https://consent.ma-atara.io/api/v1/consent/<contentHash>

# Anchors and epochs
curl "https://api.ma-atara.io/api/anchors?limit=10"
curl "https://api.ma-atara.io/api/anchors/state-proof?window=256"
curl https://aegis.ma-atara.io/api/anchor/status
curl https://keys.ma-atara.io/api/epoch/current

# Pricing, region-resolved from the caller's IP
curl https://pay.ma-atara.io/pay/offer
curl https://pay.ma-atara.io/pay/offer/provenance_yearly
curl "https://pay.ma-atara.io/pay/offer?country=GB"

aegis.ma-atara.io/health is a dependency roll-up and returns 503 if any dependency check fails, so treat a non-200 there as a real signal rather than a probe error.

Authentication

The platform auth scheme is a stateless signed assertion:

Authorization: MaataraAssertion <assertionB64u>.<signatureB64u>

The signature algorithm is ML-DSA-65 (FIPS 204). Assertion construction, the mandatory nonce, and the canonical-DID binding rules are covered in Authentication; this page only records which endpoints require an assertion and what each service does with the aud claim.

Audience

aud names the service or route group the assertion may be presented to. Audience enforcement is fail-closed at each protected native boundary; there is no native unscoped or wrong-product fallback. The complete wire namespace is maintained in packages/identity-types/src/audiences.ts and includes product, worker, and Core route-group audiences.

ServiceEndpoint(s)Required audIf it does not match
api.ma-atara.ioCore route groups: /api/notes/, /api/genesis/ and /api/inception/, /api/pair/, and the remaining protected chain/account routesnotes, genesis, core, or chain respectively401/403. The route's exact audience is required before nonce consumption or storage access
did.ma-atara.ioPOST /1.0/register; other protected resolver routesidentity for registration; identity or console for the route403 invalid_audience or an equivalent authorization failure
keys.ma-atara.ioassertion-authenticated routeskeys401 Invalid assertion; a wrong or absent audience is not accepted
aegis.ma-atara.ioassertion-authenticated registration, search and text-query routesprovenance403 wrong_audience; it is not downgraded to the anonymous tier
pay.ma-atara.ioassertion-authenticated quota and entitlement routesprovenance403 wrong_audience; billing state is not read
parable-cityprotected identity and unlock routescity403 wrong_audience
identity-bridgeprotected work-identity mutation routesidentity-bridge403 wrong_audience

Public reads and route-specific credentials are separate contracts and do not imply acceptance of an arbitrary MaataraAssertion. Set the exact audience for the route and mint a fresh nonce; with several products sharing one identity across separate origins, reusing a token across services is rejected or replay-protected.

Some services also constrain the nonce beyond the general "non-empty and unique" rule: POST /1.0/register on did.ma-atara.io requires it to match ^[A-Za-z0-9._~-]{16,64}$ (400 invalid_nonce), and the device-directory and key-transparency routes on keys.ma-atara.io — which are not listed on this page — cap it at 128 characters (403 invalid_directory_nonce).

On api.ma-atara.io the nonce is consumed on every assertion check, including reads. An assertion is single-use even on GET /api/chain/head. Mint a fresh one per request.

Signature algorithm labels on the wire

The native profile uses ML-DSA-65 (FIPS 204). Native endpoints accept one exact label and do not perform historical algorithm or shape fallback:

EndpointFieldAccepted valuesBehaviour on anything else
POST /api/pqc/verify (core)algml-dsa-65 onlyReturns 200 {"valid": false} for an unsupported or omitted label
POST /api/verify (aegis)algorithmml-dsa-65 only400 with an algorithm error
POST /api/register and /api/register/video (aegis)signature.algorithmml-dsa-65 only400; a legacy label cannot create a new provenance record

Temporary legacy account healing is retired (HTTP 410); it is not an available recovery service or native trust-path fallback. New Core chain/governance writes and Aegis registrations must emit ml-dsa-65.

Rate limits

Limits are per 60-second window. The shared limiter is keyed on CF-Connecting-IP; the only place the platform meters by identity rather than address is aegis similarity search.

Anonymous vs authenticated

This split exists on POST /api/search/* and POST /api/query/text on aegis.ma-atara.io:

TierLimitKeyHow you qualify
anonymous10 / 60 sclient IPDefault — no assertion, an unverifiable assertion, an assertion with no aud, or an assertion with any aud other than provenance
authenticated60 / 60 scaller's DIDA verifying MaataraAssertion carrying aud: "provenance"

Both tiers return X-RateLimit-Limit and X-RateLimit-Tier on success. On rejection:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Tier: anonymous

{"success":false,"error":"Rate limit exceeded","requestId":"aegis-…","tier":"anonymous","retryAfter":60}

Shared per-IP tiers

Every other route is classified by path into one of these tiers:

TierLimit / 60 sApplies to
health600/health, /api/capabilities
verify500paths starting with /api/verify — not other paths that merely contain /verify, so POST /api/pqc/verify and GET /api/capsule/{id}/verify are _not_ in this tier
read300any other GET
default200everything else
verify-batch100a path starting with /api/verify that also contains batch
ai-search60paths containing /search
ai-chat30paths containing /chat — no public route on these hosts matches today
identity30paths containing /identity
batch-sync30/sync/batch
register20paths containing /register or /commitment
epoch20paths containing /epoch
key-rotate10/key/rotate
key-revoke5/key/revoke

Service-specific deviations:

crawlers are the intended traffic. Writes fall back to the shared tiers.

not metered at all.

60 / minute for POST /api/register and 20 / minute for POST /api/register/video.

Per-route limits inside api.ma-atara.io, applied in addition to the tier above:

RouteLimit / 60 s per IP
POST /api/headers/prepare60
POST /api/assets/mint, /api/assets/transfer, /api/assets/{id}/export30 each
POST /api/inception/head30
POST /api/pqc/verify30
POST /api/anchors20
POST /api/governance/policy20
/api/pair/* (every method)20
POST /api/inception/org-officers10
POST /api/genesis/init5

POST /api/inbox/contact-request is the exception that does not use a 60-second window: 5 per 10 minutes per IP, and separately 30 per 24 hours per recipient DID, so a single owner cannot be flooded from many addresses.

Two caveats worth designing around. The shared limiter is backed by an eventually consistent store that tolerates roughly one write per second per key, so published limits are approximate under burst. When that store is degraded, the route uses a bounded in-isolate emergency bucket of 10 requests per IP per window, which is deliberately stricter than the tier table and never grants access or bypasses assertion checks. Handle 429 with Retry-After rather than assuming the tier number.

Shared-limiter 429s look like this:

{ "success": false, "error": "Rate limit exceeded", "retryAfter": 42, "tier": "read" }

Errors

Three response envelopes are in circulation. All are JSON.

Core worker structured errors — api.ma-atara.io, from validation and limiter paths:

{ "code": "invalid_request", "message": "Missing required fields" }

code is one of invalid_request, unauthorized, rate_limited, unsupported_alg, internal_error, not_found, forbidden, conflict. A details object and a retryAfter integer appear when relevant.

Core worker envelope — handler-level results and failures:

{ "success": false, "error": "Authentication required", "timestamp": 1785627968913 }

Do not match on the error string. The same 401 condition is reported as "Authentication required" on some routes and "unauthorized" on others. Branch on the status code.

Aegis envelope — every response on aegis.ma-atara.io, success or failure:

{
  "success": false,
  "error": "chunk.values must have 256 entries",
  "timestamp": 1785627939971,
  "requestId": "aegis-msb0rseb-1x6vroqr"
}

Quote requestId in any support conversation. did.ma-atara.io uses a fourth, smaller shape for its own errors: {"error": "cors_denied", "status": 403}.

StatusMeaning in this API
200Success. Note that POST /api/verify on aegis returns 200 only when the signature is valid
204CORS preflight only
301Compatibility redirect. Follow it once and update your client to the target
400Malformed body, failed validation, replayed verification nonce, or an invalid signature on aegis POST /api/verify
401Missing, malformed, expired, or replayed MaataraAssertion
402Quota exhausted, or an org action requiring an active licence
403Audience mismatch, ownership mismatch, or a browser Origin outside the allowlist
404No such record. Consent lookups return 404 with {"found": false, …} by design
405Method not allowed (docs.ma-atara.io accepts only GET, HEAD, OPTIONS)
413Body or collection above the documented cap
429Rate limited — read Retry-After
503A dependency is unavailable, or an index is not provisioned on this deployment

CORS

Server-side callers are unaffected by the origin allowlist: a request with no Origin header passes on every read surface, which is why the curl commands above work. Browser origins must be allow-listed per service.

One exception matters, because it fails in a way that looks like a bug: did.ma-atara.io requires an allow-listed Origin on mutations. POST /1.0/register sent without an Origin header returns:

{ "error": "cors_denied", "status": 403 }

Send an allow-listed Origin on POST, PUT, PATCH and DELETE to that host, including from servers. On consent.ma-atara.io, reads are open (Access-Control-Allow-Origin: *) while writes are origin-checked.


Endpoint reference

Auth column: none means no credential of any kind. assertion means a MaataraAssertion header. route-specific means the endpoint carries its own credential, described in the row.

api.ma-atara.io — core

Service metadata

MethodPathAuthPurpose
GET/, /index.htmlnoneService banner
GET, HEAD/healthnone{service, version, buildSha, timestamp} inside the success envelope
GET/api/versionnone{version, commit, buildAt}. buildAt is generated at request time, not at build time
GET/api/capabilitiesnoneCapability discovery. Served no-store and not wrapped in the success envelope
GET/api/metanoneMetadata and auth header hint
GET, HEAD/openapi.jsonnoneOpenAPI 3 document generated from the live public route table
GET/docs/API.md, /api/docs.mdnoneMarkdown rendering of the same spec
GET/robots.txtnoneAllows /health and /openapi.json, disallows the rest

GET /api/capabilities is the authoritative statement of what a deployment supports:

{
  "version": "1.5.2",
  "commit": "unknown",
  "authSchemes": ["MaataraAssertion"],
  "features": {
    "headerPrepare": true,
    "pqc": {
      "mlKem": ["ml-kem-768"],
      "mlDsa": ["ml-dsa-65"],
      "legacyRecovery": "not accepted by native Veritas v4"
    }
  },
  "storage": {
    "default": "ipfs",
    "gateway": "https://cloudflare-ipfs.com",
    "pinning": false,
    "provider": "r2",
    "providers": ["r2", "external-http"]
  },
  "policy": {
    "headerCanonicalization": "v2",
    "aadHashAlg": "sha3-384",
    "anchorHashAlg": "sha3-384",
    "anchorHashAlgs": ["sha3-384"],
    "dekWrap": {
      "hkdf": {
        "kdf": "HKDF-SHA384",
        "salt": "aadHash",
        "info": "maatara:dek-wrap:v1|ml-kem-768|aes-256-gcm|sha3-384",
        "L": 32
      },
      "supported": ["aes-kwp", "aes-gcm"],
      "recommended": "aes-kwp"
    }
  }
}

The capability response lists only the native ML-KEM-768 and ML-DSA-65 profile. Historical algorithm literals are not aliases, are not accepted by native Veritas v4, and must not be reintroduced as a compatibility fallback. storage.provider and storage.pinning are deployment-specific — read them rather than assuming them.

Chain

MethodPathAuthPurpose
POST/api/chain/appendassertionAppend a signed block to the caller's chain
GET/api/chain/headassertionHead block index and hash
GET/api/chain/blocksassertionList blocks in a range
GET/api/chain/payload/{blockId}assertionFetch one block payload
POST/api/chain/rotation/activateassertionActivate a pending key rotation
GET/api/chain/rotation/currentassertionCurrent active rotation record
GET, POST/api/security/holdassertionRead or place a chain-write freeze
POST/api/security/hold/liftassertionLift the freeze
GET/api/sync/statusassertionDevice sync and reconciliation state

Identity inception and genesis

MethodPathAuthPurpose
POST/api/genesis/initroute-specificCreate the genesis block for a new identity
GET/api/genesis/{did}noneFetch a published genesis record
POST/api/inception/headassertionRead or create the inception head. scope in the body must be the caller's own 96-hex chain scope, else 403
POST/api/inception/device, /device-event, /device-batch, /orgassertionHierarchical identity inception
POST/api/inception/org-officersroute-specificRotate org officers. Requires an outgoing-officer quorum, incoming acceptances, and an org-key continuity signature

Notes

Note bodies and attachments are encrypted on the device; these endpoints carry ciphertext.

MethodPathAuthPurpose
POST/api/notes/{did}assertion, DID ownerAppend an encrypted note
GET/api/notes/{did}assertion, DID ownerList the notes index
GET/api/notes/{did}/storageassertion, DID ownerBytes used
GET, DELETE/api/notes/{did}/{noteId}assertion, DID ownerFetch or tombstone one note
GET/api/notes/{did}/{noteId}/versions[/{v}]assertion, DID ownerVersion list, or one version
GET, PUT/api/notes/{did}/{noteId}/attachments/{attId}assertion, DID ownerFetch or upload an encrypted blob
HEAD/api/notes/{did}/cas/{cipherHash}assertion, DID ownerContent-addressed dedup probe. Owner-gated on purpose: unauthenticated it was a cross-account correlation oracle
GET/api/notes/{did}/cas/refsassertionBlob refcount index
POST/api/notes/{did}/cas/gc, /api/notes/{did}/cas/rebuildroute-specificGarbage-collect or rebuild refcounts. Authorised by a detached owner-key signature over a timestamp, accepted within ±5 minutes — not a bearer credential

Inbox

MethodPathAuthPurpose
POST/api/inbox/contact-requestnoneBegin a contact request for an assetId. Turnstile-gated; returns the owner's published public ML-KEM key for browser-side sealing
POST/api/inbox/verify-otpnoneVerify the one-time code and deliver a browser-created encrypted envelope
GET/api/inbox/{did}assertionList inbox
POST/api/inbox/{did}/{msgId}/readassertionMark read
DELETE/api/inbox/{did}/{msgId}assertionDelete a message

POST /api/inbox/contact-request requires only assetId, fromEmail, and turnstileToken. It returns {requestId, kemPublicB64u, kemKeyId}; the browser ML-KEM encapsulates to that public key and AES-256-GCM seals the name, email, subject, message, and asset context with request-bound AAD. POST /api/inbox/verify-otp accepts the OTP and that opaque envelope. Core rejects plaintext message fields and retains only the OTP-delivery email address in short-lived KV. The caller never supplies ownerDid. Core resolves the asset to its owner over the private Aegis service binding, then applies the per-recipient limit and resolves the owner's published ML-KEM key. A missing asset is 400 missing_asset; an unknown asset or unavailable internal resolution path is deliberately the same 404 owner_unresolvable, so the endpoint is not an asset-existence oracle. /reply is retired: reply through the owner's own mail client instead of posting plaintext mail content to Core.

Anchors

Anchors and the publisher model are covered in Anchors & callbacks.

POST /api/anchors records a root and creates the anchor record; writing that root to a public ledger is a separate step performed by an external publisher, which reports back over an internal callback that is not part of this contract. That step is not running on a schedule today — see the anchoring note under aegis.ma-atara.io — and the list is currently empty, so an anchor record here is a submission, not evidence of a chain write.

MethodPathAuthPurpose
GET/api/anchorsnoneList published anchors. ?limit= defaults to 100 and is capped at 500; ?cursor= pages. Returns {items, count, cursor}
GET/api/anchors/{id}noneOne anchor record
GET/api/anchors/state-proofnoneRolling SHA3-384 Merkle certificate over the most recent anchor ids. Returns {window, count, root, leaves, createdAt, hashAlg, anchors} — every leaf is included, so you recompute the root yourself. ?window= defaults to 256, clamped to 1–2048. With no anchors published it is a root over an empty leaf set
POST/api/anchorsassertionSubmit a root for anchoring
GET/api/anchors/eligibilityassertionWhether this owner should be offered an anchor

Assets and governance

MethodPathAuthPurpose
POST/api/assets/mint, /api/assets/transfer, /api/assets/{id}/exportassertionMint, transfer, export
GET/api/assets/{id}, /api/assets/{id}/receiptsassertionAsset record, receipts
GET, POST/api/governance/policyassertionRead the current policy document, or publish a version
GET/api/governance/auditassertionGovernance audit trail

Device pairing

MethodPathAuthPurpose
POST/api/pair/offerassertionCreate a pairing offer and one-time token
GET/api/pair/offer/{code}assertionRetrieve your own offer
DELETE/api/pair/offer/{code}assertion, or the pairing tokenCancel an offer
POST/api/pair/offer/{code}/joinroute-specific: pairing tokenBind a joining device key
POST/api/pair/sealassertionSeal a pairing exchange
GET/api/pair/seal/{code}route-specific: pairing tokenAtomically consume the sealed result. Single-use

Cross-device authorization relay (flag-gated)

The authorization relay delivers an approved device grant, not a root session token. Checked production source configuration enables device verification. When disabled in a destination deployment, these routes refuse with 403 device_keys_disabled. Source configuration is not a live deployment receipt. Use the approved Portal/device integration.

MethodPathAuthPurpose
POST/api/authorize/offerassertion, root onlyMint a cross-device authorization offer code and one-time token
GET/api/authorize/offer/{code}assertion, root onlyRead the joined DeviceAuthorizationRequest with a fresh assertion
DELETE/api/authorize/offer/{code}assertion (root) or the offer tokenCancel an offer
POST/api/authorize/offer/{code}/joinroute-specific: offer tokenJoining device binds its DeviceAuthorizationRequest
POST/api/authorize/grantassertion, root onlyRoot posts the DeviceGrant (attestation + wrapped MNK) for a joined offer
GET/api/authorize/grant/{code}route-specific: offer tokenJoining device atomically consumes its DeviceGrant. Single-use — deletes on read

The joining device uses its own authorized device session. Never substitute a root assertion or recovery phrase. See Authentication for the integration boundary.

Utilities

MethodPathAuthPurpose
POST/api/pqc/verifynoneVerify an ML-DSA-65 signature. See below
POST/api/headers/prepareassertionCanonical header pre-image and aadB64u (SHA3-384). The session userId must equal body.userId
GET/ipfs/{cid}noneRead-through IPFS gateway proxy
POST/api/ipfs/pinassertionPin content
POST/api/activation/redeemroute-specificRedeem an activation code
POST/api/user/verifyassertion-nonceVerify the authenticated account against the licensing store
GET, POST/invite-requestnoneInvite request form and submission

POST /api/pqc/verify verifies a signature over either a message or a SHA3-384 pre-hash:

curl -X POST https://api.ma-atara.io/api/pqc/verify \
  -H 'content-type: application/json' \
  -d '{"alg":"ml-dsa-65","signatureB64u":"…","publicKeyB64u":"…","messageB64u":"…"}'
# -> {"valid": true}
FieldRequiredNotes
algyesSend ml-dsa-65 for native evidence. An omitted or unknown value yields {"valid": false}
signatureB64uyesbase64url, unpadded
publicKeyB64uyesbase64url, unpadded
messageB64uone ofThe signed message
hashB64uone ofA pre-hash of the message
hashAlgnoOnly sha3-384 is accepted with hashB64u; anything else is 400 unsupported_alg

The response is always {"valid": boolean} with status 200. A missing field is 400 invalid_request.

Internal — not part of the public contract

Reachable only over a Worker service binding, gated by an X-Maatara-Internal shared secret compared with timingSafeEqual (fails closed when the secret is unset). No third-party caller can reach these — they are documented here because aegis.ma-atara.io and did.ma-atara.io call them to verify a version-2 (device-signed) MaataraAssertion over their CORE_WORKER binding, per PORTAL_DEVICE_KEYS_V1 §4.5, and an integrator debugging a device_index_unavailable or attestation_unverifiable refusal from aegis or did-resolver needs to know the refusal is forwarded verbatim from one of these, not invented at the edge.

MethodPathPurpose
GET/api/internal/key-scope/{authKeyUserId}Resolve a raw signing-key userId to its permanent rotation scope, or report it superseded (409)
GET/api/internal/device/{deviceId}Resolve a device key to its verified attestation record (owner DID, capabilities, revocation status) for aegis/did-resolver. ?consistency=primary bypasses both KV cache layers — capsule-registration callers MUST set it; session-minting and read routes MUST NOT
POST/api/internal/nonce/consumeAtomically consume a dev:{deviceId} or attest:{scope} nonce on core's NONCE_GUARD Durable Object, for a caller (aegis) with no NONCE_GUARD binding of its own

aegis.ma-atara.io — provenance

Metadata and verification

MethodPathAuthPurpose
GET/healthnoneDependency roll-up. 503 unless every check passes
GET/api/capabilitiesnoneAlgorithm, limit and endpoint inventory
GET/, /api/docsnoneStatic HTML API documentation
POST/api/verifynoneVerify one signature
POST/api/verify/batchnoneVerify a batch
POST/api/verify/with-key-statusnoneVerify, and report the key's current lifecycle state

The limits.rateLimit block inside /api/capabilities is advisory metadata that does not match what the limiter enforces. Use the rate-limit tables on this page.

POST /api/verify takes five fields, all required:

FieldNotes
publicKeyB64uSigner's ML-DSA-65 public key
messageB64uThe signed message
signatureB64uThe signature
nonceCaller-chosen, single-use. Replay within the retention window returns 400 Duplicate nonce - potential replay attack
algorithmml-dsa-65 for native evidence. Absent or anything else is 400

A valid signature returns 200 with data: {valid: true, keySource, nonceStatus, timingUs}. An invalid signature returns 400, not 200, with the same result object under error. Branch on the status code as well as on valid.

Registration and provenance records

Registration does not upload the original file. The worker never receives raw image bytes: capsules are produced on the device by @maatara/aegis-encoder and only the signed capsule is transmitted. The capsule's internal structure is proprietary and is not part of the public specification — treat capsule and provenance response bodies as opaque, and verify them through the endpoints below or with the encoder package.

MethodPathAuthPurpose
POST/api/registerassertion, plus capsule quotaRegister an image capsule
POST/api/register/videoassertion, plus capsule quotaRegister a video capsule
POST/api/register/textassertion, plus capsule quotaRegister text fingerprint vectors. See below
POST/api/commitmentassertionCreate a pre-registration commitment
GET/api/commitment/{id}noneFetch a commitment
GET/api/capsule/{assetId}noneFetch a capsule record
GET/api/capsule/{assetId}/verifynoneVerify a capsule's signature
GET/api/provenance/{assetId}noneReturns 501 canonical_veritas_binding_unavailable until Aegis registration appends through the authoritative Core chain
GET/api/provenance/{assetId}/pathnoneReturns 501 canonical_veritas_binding_unavailable; Aegis service records are not relabelled as Veritas ancestry
GET/api/provenance/{assetId}/proofnoneEvidence bundle — signature, public key and verification evidence, each labelled with the strength that actually backs it, plus a gaps list. Not a ledger inclusion proof: anchor is null with a stated reason. See below
GET/api/anchor/statusnoneAnchoring status. Reports what is deployed, not what is intended — see below
POST/api/keypoints/uploadassertionUpload a keypoint blob
GET/keypoints/{96hex}.akpbnoneFetch a keypoint blob

The commitment hash is salted and does not expose the source bytes. The POST /api/commitment request has a closed contract containing only commitment_hash and the optional algorithm: "sha3-384"; free-text hints, hint commitments, client timestamps, and unknown fields are rejected. GET /api/commitment/{id} returns only the canonical commitment metadata and verification information. Historical records are projected into that same shape, so legacy plaintext labels are never retransmitted.

Quota exhaustion on the register routes returns 402:

{
  "success": false,
  "error": "quota_exhausted",
  "message": "…",
  "kind": "…",
  "used": 0,
  "limit": 0,
  "tier": "…",
  "requestId": "aegis-…"
}

Automatic ledger publication is disabled in checked configuration. Read GET /api/anchor/status and independently verify any claimed transaction. Ethereum is configured against a deployed registry on chain 11155111 — Sepolia testnet, not mainnet — and Bitcoin answers {"enabled": false, "status": "not-implemented"}. The scheduled anchoring trigger is disabled, so nothing is anchored on a timer; a single epoch was anchored by hand on 30 July 2026. That is why GET /api/provenance/{assetId}/proof returns anchor: null rather than omitting the field. A capsule's signature and the service-attested timestamp are real today; a public-ledger anchor is not, and a registration must not be presented as ledger-backed.

Similarity search

MethodPathAuthPurpose
POST/api/search/fragmentoptional assertion — tier onlyFragment similarity search over registered image fingerprints
POST/api/search/semanticoptional assertion — tier onlySemantic similarity search
POST/api/search/video, /api/search/video/fragmentoptional assertion — tier onlyVideo similarity search
POST/api/query/textoptional assertion — tier onlyText similarity query. Fully specified below

Fingerprints for the image and video routes are computed on the device by @maatara/aegis-encoder. Their request and response shapes are not published here; the text query contract below is.

Client assets

Static, publicly cacheable, and exempt from the origin allowlist check — a browser on an origin outside the allowlist gets the bytes instead of a 403. Exactly three path families are exempt: /models/, /ort/, and /keypoints/{96hex}.akpb (listed in the previous table).

That exemption is not the same as Access-Control-Allow-Origin: *. The worker rewrites CORS headers on every response on its way out, so a request carrying a non-allow-listed Origin receives no Access-Control-Allow-Origin header at all and a cross-origin fetch() from such an origin still fails at the browser. Server-side callers (no Origin) and allow-listed first-party origins are unaffected. Everything else on this host — including the on-device encoder's own WASM assets, which reach the first-party app through @maatara/aegis-encoder — is origin-checked and is not part of the public contract.

MethodPathAuthPurpose
GET/models/manifest.jsonnoneManifest of hash-pinned model artefacts used by the on-device encoder
GET, HEAD/models/{file}noneFetch one artefact named in the manifest. Integrity is the caller's job: verify against the SHA3-384 pin in the manifest
GET/ort/{version}/ort-wasm*.{wasm,mjs}noneSelf-hosted onnxruntime-web runtime mirror

consent.maatara.io

consent.ma-atara.io remains an additional custom hostname during the domain cutover; the canonical product hostname is consent.maatara.io.

Discovery documents are cacheable and crawler-facing. The default policy for content with no record is opt-out.

MethodPathAuthPurpose
GET/healthnoneLiveness
GET/robots.txtnoneAI-crawler directives. Cache-Control: public, max-age=86400
GET/.well-known/ai-consentnoneMa'atara discovery document with an AI-Pref draft-06 projection and the verification endpoint. max-age=3600
GET/.well-known/tdmrep.jsonnoneTDMRep Final Community Group Report site-rule array. max-age=3600
GET/api/v1/consent/{hash}noneSingle-hash lookup. max-age=300 on a hit, 60 s on a miss
GET/api/v1/consent/by-creator/{did}noneAll records for a creator DID. max-age=120
POST/api/v1/consent/batchnoneBatch lookup, maximum 10,000 hashes per request
GET/api/v1/spawning/opt-outnoneSpawning-compatible opt-out check. max-age=120
POST/api/v1/consentroute-specific: X-Aegis-SignatureRegister a consent record
PUT/api/v1/consent/{hash}route-specific: X-Aegis-SignatureUpdate a record
DELETE/api/v1/consent/{hash}route-specific: X-Aegis-SignatureRevoke a record

A hit returns the record plus an X-Aegis-Consent response header. A miss is 404 and still carries a usable body:

{ "found": false, "hash": "0000…", "message": "No consent record found. Default policy applies." }

Writes require the X-Aegis-Signature header — its absence is 401 — plus a canonical creator_did and acting public_key_b64u. Aegis verifies over a private service binding that the key speaks for that DID and verifies the ML-DSA-65 signature over shared canonical JSON. Updates and revocations require the existing native owner DID and the acting public key; ownerless historical rows return 409 non_native_consent_record rather than being adapted.

did.ma-atara.io

The resolution endpoint follows W3C DID Core and DID Resolution content negotiation. Resolution results use application/did-resolution; a successful raw DID document may be requested with Accept: application/did or application/did+ld+json. Responses use Cache-Control: no-store.

MethodPathAuthPurpose
GET/healthnone{status, service, version, commit, method: "did:maatara", timestamp}
GET/.well-known/did.jsonnonedid:web self-description of the resolver
GET/1.0/identifiers/{did}noneResolve a DID to a full resolution result
GET/1.0/identifiers/{did}/bindingsassertion, member-scopedThe member's own org-binding roster
POST/1.0/registerassertion with aud: "identity"Owner-authenticated DID registration
GET/1.0/org/{orgDid}/officersnonePublic officer set. Edge-cached 45 s fresh, 600 s stale-while-revalidate
GET/1.0/org/{orgDid}/verify/{memberDid}noneIs this member bound to this org — {orgDid, memberDid, bound, provider?, expiresAt?}
GET/1.0/org/{orgDid}/membersorg-controller or verified-officer assertion, active licenceFederated IdP-binding roster. 402 without a licence
GET/1.0/org/{orgDid}/memberships?limit=50&cursor=...org-controller or verified-officer assertion, active licenceCurrent folded membership roster: role, status, permission vocabulary, expiry/suspension window, and latest event provenance. Separate from IdP bindings; cursor-paginated (1–200). 402 without a licence
POST/1.0/org/{orgDid}/bind, /revokeorg-owner assertion, active licenceBind or revoke an IdP subject
GET, POST/api/rotation/requestassertion, DID-controller authorityRequest or poll a key rotation
POST/api/rotation/activatedassertion, DID-controller authorityMark a rotation activated. Bookkeeping only

Status mapping, with the error carried in didResolutionMetadata.error:

StatusdidResolutionMetadata.errorMeaning
200absentResolved; didDocument is populated
400https://www.w3.org/ns/did#INVALID_DIDThe identifier is not a well-formed did:maatara
404https://www.w3.org/ns/did#NOT_FOUNDWell-formed, but no native document is published
500https://www.w3.org/ns/did#INTERNAL_ERRORThe native authority projection is unavailable
{
  "@context": "https://w3id.org/did-resolution/v1",
  "didDocument": null,
  "didResolutionMetadata": {
    "contentType": "application/did+ld+json",
    "error": "notFound",
    "retrieved": "2026-08-01T23:46:07.784Z"
  },
  "didDocumentMetadata": {}
}

keys.ma-atara.io

Authenticated routes on this host take a MaataraAssertion carrying aud: "keys"; an assertion with no aud, or a different one, is rejected. Legacy bearer/session credentials are not accepted on native routes. A missing Authorization header is 401 Authorization required. Browser origins must exactly match the worker's ALLOWED_ORIGINS list; an unset list never enables a development wildcard. Server-to-server requests without Origin remain supported, while public mutation helpers require an allowlisted Origin.

MethodPathAuthPurpose
GET/healthnoneStatus and binding inventory. Not rate limited
GET/api/capabilitiesnoneCapability and policy list (retired: [...] names what used to be here). Not rate limited
PUT/api/key-directory/{accountDid}/devices/{deviceId}root assertionPublish a signed DID-bound device credential and profile key package to the canonical KeyDirectoryDO
GET/api/key-directory/{accountDid}/devicesassertionList the account's public device packages through the strongly consistent directory
GET/api/key-directory/{accountDid}/devices/{deviceId}?profile=...&suiteId=...assertionResolve one device/profile package; no one-time prekey is returned by this read
POST/api/key-directory/{accountDid}/devices/{deviceId}/prekeys/reserveassertion + Idempotency-KeyAtomically reserve a compatible prekey under an explicit fallbackPolicy; bound v4 requires one-time-required
POST/api/key-directory/{accountDid}/devices/{deviceId}/revokeroot assertionRevoke the device package and supersede its prekeys
POST/api/prekeys/upload, /api/prekeys/replenishassertionSunset-bound migration inventory only; not accepted for native Veritas v4 or new channel bootstrap
GET/api/prekeys/{userId}assertionSunset-bound migration inventory only; not accepted for native Veritas v4 or new channel work
POST/api/guardian/attestassertionGuardian threshold attestation
GET/api/guardian/status/{id}noneGuardian attestation status
GET/api/epoch/currentnoneCurrent public epoch. Served no-store
POST/api/epoch/join, /api/epoch/ackassertionEpoch participation. user_id in the body must be the authenticated owner, else 403
GET/api/transparency/proof/{id}, /api/transparency/audit/{id}noneKey-transparency proof and audit. Edge-cached for 5 s with no stale window

Retired (PORTAL_DEVICE_KEYS_V1 §9.1 / ADR-0006), all now 410 endpoint_retired: the Key Status Registry (/api/key/status/, /api/key/history/, /api/key/rotate, /api/key/revoke, /api/key/report-compromise — never populated with anything a verifier consulted; core-worker's KeyLifecycleDO is the key-lifecycle authority); identity tiers (/api/identity/register, /api/identity/upgrade, /api/identity/{id}/tier — backed by a literal reputationScore field, which claim E retires); the legacy device-attestation surface (/api/identity/device/attest, /api/identity/devices, /api/identity/device/{id}, /api/identity/device/{id}/revoke — zero callers, superseded by device-attest.v1 / device-revoke.v2 on the account chain); the "Key Lifecycle" admin blade (/api/admin/keys, /api/admin/keys/{id}/revoke, /api/admin/keys/{id}/advisory — read/wrote the now-deleted UserKeyChainDO); the trust kernel and Dunbar layers (/api/trust/kernel/{id}, /api/trust/delegate, /api/trust/attestation, /api/trust/dunbar/{id}/{layer}, /api/trust/dunbar/assign — claim E: there is no device or key reputation system); and the two internal service-binding routes that only ever fed the deleted surfaces above (/api/internal/owner/register, /api/internal/device-key/resolve). UserKeyChainDO and the USER_KEYCHAIN binding are deleted; edge-key-fabric/src/{ksr,do/UserKeyChainDO.ts, identity/{device,device-key,tiers}.ts,internal/owner.ts,trust} no longer exist.

In the current source contract, the DID-bound KeyDirectoryDO is the canonical directory for new channel work because publication, revocation, and one-time-prekey reservation are serialized in one Durable Object. Its capability document currently reports keyDirectoryChannelReady: false: the implementation exists, but no secure-messaging production profile is active.

No epoch is active on production at the time of writing — GET /api/epoch/current returns {"epoch": null, "message": "No active epoch"}. Treat epoch participation as scaffolded rather than in service.

auth.ma-atara.io

Human sign-in only. This service does not issue credentials that protocol endpoints accept: core APIs still require a MaataraAssertion.

An OPAQUE login may authorize a client to unlock a locally wrapped, application-scoped secret. It must not be used to derive the DID signing root, secure-channel root, and recovery root from one export key; those roots remain independently generated and separately wrapped.

MethodPathAuthPurpose
GET/healthnone{service, version, commit, environment}. Not rate limited
POST/api/auth/turnstile/verifynoneCloudflare Turnstile verification and trust score
POST/api/auth/opaque/register/init, /register/finish, /login/init, /login/finishprotocol-internalOPAQUE aPAKE registration and login
POST/api/auth/passkey/register, /api/auth/passkey/authenticateprotocol-internalWebAuthn / FIDO2
GET/api/auth/google/start, /api/auth/google/callbacknoneGoogle OAuth onboarding, redirect flow
GET/api/auth/microsoft/start, /api/auth/microsoft/callbacknoneMicrosoft Entra / MSA onboarding
POST/api/auth/onboard/ticket/verifyroute-specific: onboarding ticketVerify an onboarding ticket

Every route except /health is limited to 20 requests per minute per IP. Unmatched paths return {"success": false, "error": "Not found"} with 404.

pay.ma-atara.io

MethodPathAuthPurpose
GET/healthnoneLiveness
GET/noneService banner
GET/pay/offernoneRegion-aware offers: {region, recommended, offers[], tiers, note}
GET/pay/offer/{sku}noneOne SKU's price for the caller's region. 404 unknown_sku
POST/pay/intentassertionCreate a payment intent
GET/entitlementassertionCurrent entitlement
POST/entitlement/claimnoneRetired compatibility route. Always 410 entitlement_claim_retired
POST/entitlement/redeemassertionRedeem an entitlement
GET/quotaassertionQuota meters
POST/quota/deltaassertionAdjust a quota meter
POST/quota/consumeassertionAtomic check-and-increment. 402 when the increment would breach the tier cap
POST/quota/refundassertionReturn a previously consumed unit. Floored at zero, so a double refund cannot manufacture allowance

Region is resolved from the caller's IP and can be overridden with ?country=. Tiers are notes, provenance, atelier and research; Notes is free on signup and Research is contact-sales.

curl https://pay.ma-atara.io/pay/offer/provenance_yearly
{
  "sku": "provenance_yearly",
  "display": "Provenance (yearly)",
  "kind": "subscription",
  "tier": "provenance",
  "interval": "year",
  "price": { "amount_minor": 12000, "currency": "AUD", "display": "A$120/yr" },
  "region": "AU"
}

CORS applies to /pay/, /entitlement and /quota*.

docs.ma-atara.io

Documentation is open in production — no token is required. Only GET, HEAD and OPTIONS are accepted; anything else returns 405 with Allow: GET, HEAD, OPTIONS.

MethodPathAuthPurpose
GET/healthnoneLiveness, no-store
GET/noneDocumentation home
GET/developer, /developer/{slug}noneDeveloper documentation
GET/academic, /academic/{slug}noneAcademic documentation
GET/sitemap.xml, /robots.txtnoneSitemap and crawler directives

Every response carries HSTS (2 years, includeSubDomains; preload), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, and a CSP of default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'.


Text similarity: the current contract

Both text endpoints on aegis.ma-atara.io accept vectors only. They no longer accept prose in any form.

Plaintext is rejected

If the request body contains any of text, title, body or content, the request fails with 400 and this exact message — the offending field name is interpolated:

{
  "success": false,
  "error": "this endpoint no longer accepts 'text'. Fingerprint on the device with @maatara/aegis-pure and send vectors.",
  "timestamp": 1785627939788,
  "requestId": "aegis-msb0rs98-jrtgirxh"
}

The check is for presence of the key, not its value: "text": null and "text": "" are rejected exactly like "text": "hello world". Fields are checked in the order text, title, body, content, and the first one found is named in the message. On POST /api/register/text this check runs after authentication, so an unauthenticated caller sees 401 first.

Fingerprint on the device

Vectors are produced locally by @maatara/aegis-pure, which is dependency-free and runs identically in a browser, a worker and Node.

npm install @maatara/aegis-pure @maatara/pqc-toolkit
import { fingerprintText, fingerprintQuery } from '@maatara/aegis-pure';
import { createProtocolWasm } from '@maatara/pqc-toolkit';
// Browser/Vite asset import; see Packages for other runtimes.
import wasmUrl from '@maatara/pqc-toolkit/wasm/binary?url';

const proto = createProtocolWasm();
const glue = await import('@maatara/pqc-toolkit/wasm/glue');
await proto.init(wasmUrl, { glue });
const sha3String = (s: string) => proto.hash.sha3String(s); // SHA3-384 hex of a UTF-8 string

// Registration side — returns the asset id, counts and the ±1 vectors.
const fp = fingerprintText(sha3String, documentText);

// Query side — same pipeline, capped so a long query cannot fan out.
const q = fingerprintQuery(sha3String, queryText);

The one input you supply is sha3String. Both sides use the same WASM implementation, so the digest is identical by construction rather than by agreement.

The package returns camelCase; the wire is snake_case. Neither function returns a request body, and posting one verbatim fails validation. Map it yourself:

// POST /api/register/text
{ asset_id: fp.assetId, word_count: fp.wordCount, chunks: fp.chunks }

// POST /api/query/text
{ vectors: q.values, word_count: q.wordCount, chunk_count: q.chunkCount }

fp.chunks already has the field names the endpoint wants (index, values, start, words), so only the two top-level keys are renamed. On the query side the array is called values in the package and vectors on the wire — send q.values under the name vectors, or you get 400 vectors must be a non-empty array. Use the released query defaults and honor the endpoint limits.

What this means for trust. Because the server can no longer re-derive a fingerprint from the text, it cannot check that a submitted vector corresponds to any particular prose. Abuse is constrained by the caller's quota and their authenticated DID, not by content verification. There is no server-side attestation that a registered fingerprint matches any specific document.

POST /api/register/text

Requires a MaataraAssertion and consumes capsule quota. Returns 503 text index not provisioned if the deployment has no text index bound.

{
  "asset_id": "text-0123456789abcdef0123456789abcdef",
  "word_count": 1234,
  "chunks": [
    { "index": 0, "values": [1, -1, "… exactly 256 entries, each exactly 1 or -1 …"], "start": 0, "words": 200 }
  ]
}
CheckMessageStatus
JSON parsesinvalid-json400
No plaintext fieldssee above400
asset_id matches ^text-[0-9a-f]{32}$asset_id must match text-<32 lowercase hex>400
chunks is a non-empty arraychunks must be a non-empty array400
At most 5000 chunkstoo many chunks413
word_count is an integer ≥ 5word_count must be an integer >= 5400
Each chunk is an objectchunk must be an object400
values is an arraychunk.values must be an array400
values has 256 entrieschunk.values must have 256 entries400
Each value is exactly 1 or -1chunk.values entries must be exactly 1 or -1400
index is a non-negative integerchunk.index must be a non-negative integer400

Success is 200 with a raw body — no {success, data} envelope — and an X-Request-ID header:

{ "asset_id": "text-…", "chunk_count": 12, "word_count": 2400, "duplicate": false, "registered_at_ms": 1754006400000 }

Registration is idempotent: re-registering the same asset_id returns 200 with "duplicate": true. The stored manifest carries counts and the asset id only — no prose, no excerpt, no title.

POST /api/query/text

Unauthenticated by design. An index nobody can query is useless. Sending a verifying assertion with aud: "provenance" raises the rate limit from 10 to 60 per minute and changes the metering key from your IP to your DID; it does not change the result set.

curl -X POST https://aegis.ma-atara.io/api/query/text \
  -H 'content-type: application/json' \
  -d '{"vectors":[[1,-1, … 256 entries …]],"limit":20,"minScore":0.6}'
FieldRequiredNotes
vectorsyesNon-empty array of vectors, at most 16. Each is exactly 256 entries of 1 or -1
limitnoDefaults to 20, clamped to 1–100
minScorenoDefaults to 0.6, clamped to 0–1. Matches below it are dropped
word_countnoEchoed back; never used in matching
chunk_countnoEchoed back; never used in matching

Validation failures, all 400: invalid-json; the plaintext rejection above; vectors must be a non-empty array; at most 16 query vectors; and the per-vector messages chunk.values must have 256 entries and chunk.values entries must be exactly 1 or -1.

Success is 200, raw JSON, best score per asset, sorted descending:

{
  "matches": [{ "asset_id": "text-…", "owner_ref": "0123456789abcdef0123456789abcdef", "chunk_idx": 3, "score": 0.87 }],
  "query_word_count": 500,
  "query_chunk_count": 3,
  "query_chunks_used": 3
}

owner_ref is not the DID. It is a stable, opaque reference derived from the owner's DID under a domain-separated SHA3-384. An anonymous caller can learn that two matches share an owner, and cannot invert the value to recover a DID. Do not read it as anonymity: it is a pseudonym — a deterministic function of an identifier that is public elsewhere in this system (ownerDid is a required field of every Veritas v4 block, and GET /api/capsule/{assetId} is unauthenticated). Anyone holding a _candidate_ DID can compute that DID's owner_ref and confirm a match. The reference reduces direct identifier disclosure; it is not a guarantee against attribution. This endpoint previously returned the raw DID — if you have code that reads did from a match, it is reading a field that no longer exists.


Packages

Four package manifests are publishable in the source tree and all four have npm releases. The two core packages have paired 2.2.1 workspace and registry versions, checked on 21 September 2026. Version equality alone does not establish artifact identity or service deployment.

PackageSource-tree versionnpm registryUse
@maatara/pqc-toolkit2.2.12.2.1Low-level crypto plus experimental pairwise- and hybrid-channel entry points
@maatara/veritas-chain2.2.12.2.1Provenance-only chain model, canonical validation, receipts, and Merkle anchor tree; no secure-channel API
@maatara/aegis-pure0.2.00.2.0Dependency-free provenance helpers, key-status validity, on-device text fingerprinting
@maatara/aegis-encoder0.1.00.1.0On-device provenance capsule encoding, browser-side

This page describes reviewed integration contracts; service builds and package releases are separate. Confirm the registry versions before pinning an external integration. Secure-messaging orchestration remains private in @maatara/secure-channel, with no production profile active.

Not documented here

Internal service-binding endpoints, administrative surfaces, debug routes and deprecated compatibility redirects are deliberately absent. They are not part of the public contract, are not supported for third-party use, and may change or disappear without notice. If you find one, do not build on it. The three /api/internal/* device-keys routes under api.ma-atara.io are the one deliberate exception, listed above because their refusals surface through aegis and did-resolver responses an integrator will actually see — that entry does not make them callable from outside a service binding.