Skip to content

Platform API Contract (Overview)

Base URL: the resolver daemon (default http://127.0.0.1:8080). Through the web application the same public routes are available under /api (e.g. https://did.is/api/v1/resolve/…); monitoring and opt-in commercial control/customer/session core routes are not. The separate loopback-only cookie BFF has its own narrow local customer pilot contract.

  • Identifiers in paths may be sent raw (/v1/resolve/did:web:example.com) or fully percent-encoded (/v1/resolve/did%3Aweb%3Aexample.com). The daemon decodes the raw path once: a fully encoded did%3Aweb%3Aexample.com%253A8443 becomes did:web:example.com%3A8443. In DID URLs, encode # as %23.
  • JSON members are camelCase. Timestamps are RFC 3339 UTC with second precision (2026-10-01T12:00:00Z).
  • Limits: request bodies ≤ 512 KiB (413), requests time out after 45 s (408).
  • Rate limiting: per-client token bucket (DIDIS_RATE_LIMIT_PER_MIN, default 120/min). Exceeding it returns 429 with Retry-After: 1. /health is exempt.
  • Security headers: X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer. Public CORS is open. Monitoring is operator-authenticated; opt-in commercial core routes require the operator token, a project API key, or the separate verified-session bridge capability, according to route. Public Explorer remains anonymous even when Authorization is supplied.

All errors are RFC 9457 problem details (application/problem+json on /v1, embedded in didResolutionMetadata.error on /1.0).

{
"type": "https://www.w3.org/ns/did#NOT_FOUND",
"title": "Not found",
"status": 404,
"detail": "https://example.com/.well-known/did.json returned HTTP 404",
"code": "NOT_FOUND",
"trace": [ { "stage": "web.fetch", "label": "…", "status": "FAIL", "startedUs": 120, "durationUs": 48210, "detail": "…" } ]
}
code Type base /v1 status /1.0 status
INVALID_DID https://www.w3.org/ns/did# 400 400
INVALID_DID_URL W3C 400 400
INVALID_OPTIONS W3C 400 400
NOT_FOUND W3C 404 404
REPRESENTATION_NOT_SUPPORTED W3C 406 406
METHOD_NOT_SUPPORTED W3C 501 501
FEATURE_NOT_SUPPORTED W3C 501 501
INVALID_DID_DOCUMENT W3C 422 500
INTERNAL_ERROR W3C 500 500
EGRESS_BLOCKED https://did.is/problems# 403 500
UPSTREAM_UNAVAILABLE DID.is 502 500
INVALID_REQUEST DID.is 400 —
PAYLOAD_TOO_LARGE DID.is 413 —
UNAUTHORIZED DID.is 401 —
RATE_LIMITED DID.is 429 429

/v1/resolve and the SSE error event attach trace (the stages that ran before the failure).

3. DID Resolution v1 HTTPS binding (W3C Candidate Recommendation Draft)

Section titled “3. DID Resolution v1 HTTPS binding (W3C Candidate Recommendation Draft)”
Accept Response
application/did-resolution or application/ld+json;profile="https://w3id.org/did-resolution" 200 resolution result { "@context", "didDocument", "didResolutionMetadata", "didDocumentMetadata" }
absent, */*, application/json, application/did, application/did+json, application/did+ld+json, application/ld+json 200 DID document only, Content-Type: application/did
anything else 406 REPRESENTATION_NOT_SUPPORTED

A deactivated DID returns 410 with the full resolution result (didDocumentMetadata.deactivated: true). Errors return the resolution result with didResolutionMetadata.error and the status from §2.

If the identifier contains /, ? or # (encoded as %23), it is dereferenced: { "@context", "dereferencingMetadata", "contentStream", "contentMetadata" }.

DID URL contentStream contentType
did…#fragment The verification method or service whose absolute id matches application/json
did…?service=<id>[&relativeRef=<ref>] Service endpoint URL (relativeRef joined; dot segments, authorities and origin changes rejected) text/uri-list
did…?versionId= / versionTime= / versionNumber= The selected did:webvh version application/did
did…/path 501 FEATURE_NOT_SUPPORTED —
{
"did": "did:web:example.com",
"method": "web",
"didDocument": { … },
"didResolutionMetadata": { "contentType": "application/did", "retrieved": "…", "durationMs": 212 },
"didDocumentMetadata": { … }, // webvh: versionId, versionTime, created, updated, nextVersionId, ttl …
"verdict": { "outcome": "RESOLVED", "headline": "…", "statements": ["…"] },
"dimensions": [ { "id": "control", "label": "Update authority", "state": "NOT_ESTABLISHED",
"statement": "…", "proves": "…", "doesNotProve": "…" } ],
"evidence": {
"source": { "kind": "HTTPS", "url": "…", "httpStatus": 200, "contentType": "…", "bytes": 1234, "sha256": "…", "peerAddress": "…", "redirects": [], "latencyMs": 180 },
"document": { "idMatches": true, "contexts": [ { "uri": "…", "known": true } ], "relationships": { … }, "jcsSha256": "…", "warnings": [] },
"keys": [ { "id": "…", "type": "Multikey", "relationships": ["authentication"], "status": "VALID_KEY", "curve": "Ed25519", "jwkThumbprint": "…", "multicodec": { … } } ],
"services": [ … ],
"domainBinding": { "status": "VERIFIED", "origin": "https://example.com", "configurationUrl": "…", "credentials": [ … ] },
"tls": { "host": "example.com", "validatedBy": "…", "protocol": "…", "certificate": { … }, "daysUntilExpiry": 61, "hostnameInSan": true },
"history": { "scid": "…", "entries": [ … ], "witness": { … } } // did:webvh only
},
"graph": { "nodes": [ … ], "edges": [ { "from": "…", "to": "…", "relation": "signed", "state": "VERIFIED" } ] },
"trace": [ … ],
"limitations": [ "…" ],
"resolver": { "name": "DID.is resolver-core", "version": "0.1.0" },
"observedAt": "…",
"cached": false
}
  • Dimension states: ESTABLISHED, SELF_CERTIFYING, NOT_ESTABLISHED, FAILED, INDETERMINATE, NOT_APPLICABLE.
  • Key status: VALID_KEY, INVALID_KEY, UNSUPPORTED_KEY.
  • Domain binding status: VERIFIED, NOT_CONFIGURED, NOT_LINKED, INVALID_PROOF, UNSUPPORTED_PROOF, UNREACHABLE, MALFORMED.
  • Graph edge state: VERIFIED (a check passed), OBSERVED (retrieved), DECLARED (asserted by the document), FAILED.

text/event-stream. Events, in order: zero or more stage (one trace stage each, emitted as it completes), then exactly one result (the enriched resolution) or error (problem details), then done ({}).

As §3 dereferencing, as plain JSON; errors per §2.

{ "did", "observedAt", "verdict", "nodes", "edges" }.

{ "did", "count", "history": [ { "id", "did", "documentHash", "observedAt", "lastSeenAt", "seenCount", "latencyMs", "resolverVersion", "domainBindingStatus", "summary" } ], "note" }. Observations record what DID.is retrieved and when; they are not proofs of history.

GET /v1/diff/{did}[?from=<hash>&to=<hash>]

Section titled “GET /v1/diff/{did}[?from=<hash>&to=<hash>]”

Defaults to the latest two distinct observations; 404 if fewer exist.

{ "did", "fromHash", "toHash", "fromObservedAt", "toObservedAt", "identical", "keysAdded", "keysRemoved", "keysRotated", "relationshipsChanged": [ { "relationship", "added", "removed" } ], "servicesAdded", "servicesRemoved", "servicesChanged", "controllerChanged", "alsoKnownAsChanged", "contextsChanged", "domainBindingFrom", "domainBindingTo", "domainBindingChanged", "changedMembers", "summary", "fromDocument", "toDocument" }

Body: { "credential": <object | raw JSON string | compact JWT string>, "expectedAudience": <optional non-empty string> } (a bare credential object is also accepted).

Duplicate decoded JSON member names are rejected recursively before credential interpretation. Ambiguous HTTP bodies produce HTTP 400 INVALID_REQUEST; ambiguous credential/JWT strings produce MALFORMED. Raw JSON strings retain member identity discarded by callers that parse first.

expectedAudience checks JWT aud against an explicit recipient: missing/mismatching audience → INVALID; matching string/array → passes that check. Without it, jwt.aud is SKIP and limitations say audience was not verified. A JWT audience constraint cannot be established for a Data Integrity credential (INDETERMINATE). Invalid option shape produces HTTP 400.

VC-JWT 1.1 uses the legacy vc claim, typ: JWT when present, registered claim mapping, and conflict rejection. VC-JOSE uses a direct VC DM 2.0 payload; vc/vp claims are forbidden and present typ must identify vc+jwt. exp/nbf are independent constraints, not overridden by VC dates. See validation policy for the pinned standards and exact subset.

Response CredentialVerification:

Member Meaning
status VALID, INVALID, EXPIRED, NOT_YET_VALID, REVOKED, SUSPENDED, UNSUPPORTED, INDETERMINATE, MALFORMED
headline One sentence
format / dataModel DATA_INTEGRITY, VC_JOSE_COSE (typ: vc+jwt), VC_JWT_1_1, UNKNOWN / 2.0, 1.1, unknown
issuer, subject, types, validFrom, validUntil Extracted claims
checks[] { id, label, status: PASS/FAIL/WARN/SKIP/UNSUPPORTED/INDETERMINATE, detail }
proof { format, suite, verificationMethod, proofPurpose, created, keyCurve, signedDigest, detail }
statusList[] { statusType, purpose, index, listUrl, statusSize, listCredentialVerified, listLengthBits, value, result, window, windowOffset, detail }
issuerResolution Summary of the issuer DID’s resolution
limitations, valid, errors —

When several conditions apply the status precedence is MALFORMED > INVALID > UNSUPPORTED > REVOKED > SUSPENDED > EXPIRED > NOT_YET_VALID > INDETERMINATE > VALID.

Proof formats: Data Integrity eddsa-jcs-2022 and ecdsa-jcs-2019 (P-256); VC-JOSE vc+jwt and VC-JWT 1.1 (EdDSA, ES256, ES256K). eddsa-rdfc-2022, ecdsa-rdfc-2019, ecdsa-sd-2023, bbs-2023 and legacy JSON-LD suites → UNSUPPORTED. The signing key must be in the issuer DID’s assertionMethod. A valid signature by a did:key whose holder cannot be bound to a non-DID issuer → INDETERMINATE.

Status: BitstringStatusListEntry and legacy StatusList2021Entry. List proofs, types/context, all present validity fields, JWT constraints, purpose membership, minimum 131072 entries and index range are checked. Only single-bit revocation/suspension is supported; other semantics are UNSUPPORTED. Same-issuer authorization is service policy, not a universal standards requirement. Retrieval/list-verification failures are INDETERMINATE (fail-closed). More than 8 entries is INDETERMINATE, not silent truncation. Limits: 1 MiB download, 16 MiB decompressed.

Document, A2A, and MCP canonical fingerprints identify jcsProfile: rfc8785-binary64-v1. Historical evidence without a profile is not relabeled. MCP drift may be PROFILE_CHANGED when the previous snapshot used a different/unversioned hash profile; this is not tool behavior drift. The corrected engine may produce different numeric hashes; raw transport-byte hashes are unaffected.

Body: { "did": "…", "policy": { "name": "optional", "rules": { … } }, "credential": <optional> }. Response: { "evaluation": PolicyEvaluation, "credential": CredentialVerification | null, "resolution": { "verdict", "dimensions" } | null }.

Rule Type Passes when
didResolution "required" The DID resolves and is not deactivated
allowedMethods string[] Method (e.g. web) is listed
allowedCurves string[] Every valid signing key uses a listed curve (INDETERMINATE if only unsupported keys exist)
allowedKeySuites string[] Every verification method type is listed
minKeyBits integer The weakest valid signing key meets the size
requireKeyRelationship string A valid signing key is authorised for the relationship
domainBinding "required" | "optional" Linkage VERIFIED (required)
tlsMinDaysRemaining integer Certificate validity remaining ≥ value
verifiableHistory "required" A verified did:webvh log exists
maxCacheAgeSeconds integer The evidence is fresh enough
credentialStatus "active" The supplied credential is VALID
credentialIssuerMustBeSubject boolean The credential issuer equals the evaluated DID

PolicyEvaluation: { did, policyName, status: PASS|FAIL|INDETERMINATE|NOT_APPLICABLE, headline, rulesEvaluated, rulesPassed, evaluations: [ { rule, status, message, expected, observedValue } ], evaluatedAt }. Missing evidence makes a rule INDETERMINATE; a policy without rules is INDETERMINATE.

Inspects a Streamable HTTP MCP server. Current protocol 2026-07-28: server/discover, then paginated tools/list (≤ 10 pages, ≤ 500 tools), each request carrying MCP-Protocol-Version and Mcp-Method. Fallback 2025-11-25: initialize → notifications/initialized → tools/list → DELETE session. JSON and SSE responses are accepted (2 MiB cap). A 401 is reported with WWW-Authenticate and protected-resource metadata; DID.is never authenticates.

Response: { endpoint, status: INSPECTED|AUTH_REQUIRED, mode: STATELESS|SESSION|UNKNOWN, negotiatedVersion, supportedVersions, serverInfo, capabilities, instructions, tools: [ { name, title, description, definitionSha256, schemaSha256, parameters, annotations, declaredClass, heuristicClass, riskSignals, drift, inputSchema } ], inventoryHash, classCounts, drift: { status, previousObservedAt, previousInventoryHash, added, removed, changed }, auth, transcript, checks, headline, tls, limitations }.

definitionSha256 is SHA-256 over the RFC 8785 form of the tool definition; inventoryHash over the sorted fingerprints. declaredClass comes from the server’s annotations, heuristicClass from DID.is’s reading of names and schemas — they are reported separately and never merged.

Fetches /.well-known/agent-card.json (falling back to /.well-known/agent.json) or the given card URL (512 KiB cap). Response: { cardUrl, httpStatus, cardSha256, canonicalSha256, specVersion, name, description, version, provider, documentationUrl, interfaces: [ { url, protocolBinding, protocolVersion, https, sameOrigin } ], capabilities, securitySchemes, skills, signatures: [ { alg, kid, jku, status, detail } ], checks, headline, card, tls, limitations }. Signatures are JWS over the RFC 8785 card without signatures; keys from a DID kid (assertionMethod) or an HTTPS jku.

Delegation Receipt v1 (experimental, DID.is-defined)

Section titled “Delegation Receipt v1 (experimental, DID.is-defined)”

A receipt is a compact JWS:

  • Protected header: { "alg": "EdDSA" | "ES256" | "ES256K", "typ": "didis-delegation+jwt", "kid": "<iss>#<key>" } — the kid must be a DID URL of the issuer.
  • Payload: { "iss": <delegator DID>, "aud": <delegate DID>, "cap": [<capability>…] (1–64), "exp": <unix>, "nbf": <unix> (or "iat"), "jti"?: <id>, "prf"?: <hex SHA-256 of the parent receipt's compact serialisation> }; exp must be later than nbf.

A chain is ordered root first. Each link must: be signed by a verification method in the issuer’s capabilityDelegation; have iss equal to the previous link’s aud; carry prf equal to the previous receipt’s digest (and the root must not carry one); lie within the parent’s nbf–exp window; grant only capabilities covered by the parent’s; be currently valid (60 s skew on nbf). No principal may appear twice, and a chain holds 1–8 receipts. If trustedRoots is given, the root issuer must be listed.

Capabilities: * grants every tool; <label>:* grants every tool; <label>:<tool> and <tool> grant that tool (case-insensitive). Labels such as invoke: are descriptive and do not partition tools. A child capability is covered when every tool it grants is granted by a parent capability.

Statuses: VALID, INVALID, INDETERMINATE (e.g. an issuer DID could not be resolved), MALFORMED.

Body: { "chain": ["<jws>", …], "trustedRoots": ["did:…"]?, "register": false }. Response: { "result": DelegationChainResult, "registered": bool }. DelegationChainResult: { status, headline, format, root, leaf, effectiveCapabilities, notAfter, trustedRoot, links: [ { index, issuer, audience, capabilities, notBefore, expires, jti, kid, alg, digest, signatureValid, status, issuerHeadline, checks } ], checks, limitations }. With register: true, a VALID chain is added to the in-memory registry used by verify-tool.

Body: { "chain": […], "tool": "transfer", "trustedRoots": […]? }. Response: { "authorization": { agent, tool, decision: ALLOW|DENY, authorized, reason, matchedCapability, root, expires }, "chain": DelegationChainResult }. 400 if tool is missing.

GET /v1/agents/verify-tool?agent=<did>&tool=<name>[&root=<did>]

Section titled “GET /v1/agents/verify-tool?agent=<did>&tool=<name>[&root=<did>]”

Authorises against registered, unexpired chains only. Registrations are stored as receipt chains in the core database and re-verified after a restart; a chain that no longer verifies is dropped. Response: the authorization object. The default is DENY.

GET /v1/fast-verify/did/{did}[?noCache=true]

Section titled “GET /v1/fast-verify/did/{did}[?noCache=true]”

{ did, resolved: true, outcome, headline, statements, dimensions: [ { id, state } ], keys: [ { id, curve, status, relationships, jwkThumbprint } ], domainBinding, documentSha256, observedAt, cached }; errors per §2.

Body: { "jws": "<compact JWS>", "relationship": "assertionMethod"? }. The kid must be a DID URL; the key is looked up in relationship (default: assertionMethod, then authentication). Response: { status: VALID|INVALID|UNSUPPORTED|INDETERMINATE|MALFORMED, valid, headline, alg, kid, typ, payload, signingInputSha256, signer?, relationship? }. alg: none, b64: false and any crit extension are refused.

Enabled only when DIDIS_ADMIN_TOKEN (≥ 24 characters) is set; otherwise 501. Requests need Authorization: Bearer <token> (401 otherwise). Not exposed by the web proxy.

Route Purpose
POST /v1/monitoring/watches Body { did, webhookUrl, intervalSeconds? (60–86400) } → 201 { watch, secret, note }. The secret is shown once.
GET /v1/monitoring/watches { watches: [ { id, did, webhook, intervalSeconds, createdAt, lastHash, lastStatus, lastCheckedAt, active } ] } — webhook URLs are redacted to origin + path
DELETE /v1/monitoring/watches/{id} 204 or 404
POST /v1/monitoring/watches/{id}/check Immediate resolution/state check → `{ eventId, changed, delivery: queued
GET /v1/monitoring/events[?watchId=] { events: [ { id, watchId, did, eventType, data, createdAt, deliveryStatus, attempts } ] }
GET /v1/monitoring/status Admin-only aggregate gauges: watches, events, eventsByStatus, dueDeliveries, expiredLeases, eventPayloadBytes, watchLimit, eventLimit, eventPayloadLimitBytes; no payloads/secrets; storage failure is 500, not zero usage

GET /v1/monitoring/retention-preview?before=<RFC3339> is admin-only and returns dryRun: true, the explicit cutoff, aggregate candidateCounts (observations, mcpObservations, terminalMonitorEvents), preservation rules and a limitations note. A missing/invalid cutoff is 400; storage failure is 500, not zero counts. This read-only proposal excludes the latest row per DID/endpoint, recently seen observations, invalid timestamps, unresolved/dead-letter monitor events and active claims. Only old delivered/cancelled events without claims qualify. Comparison is strictly before the instant (timezone offsets supported). It exposes no record identifiers or payloads. No deletion/archive endpoint, scheduler, disk-space saving estimate or retention authorization is provided; historical diff evidence may depend on candidate rows.

Global safety budgets are 500 stored watches, 10,000 stored events (including terminal history), and 256 KiB serialized JSON per event. The watch limit is transactionally enforced (429 on registration). Event overflow is a persistence error and rolls back the check baseline (500), preserving the transition for a later retry. There is no automatic deletion, replay, or tenant quota policy; monitor storage caps do not bound observation/MCP history or the whole database.

Webhook URLs must pass the egress policy (HTTPS, public addresses). A poller checks due watches every 30 s.

Delivery: POST with JSON envelope { id: "evt_<n>", type, did, watchId, createdAt, data }, persisted with watch state in a single transaction. Stale watch revisions cannot insert duplicate events. A due event is claimed with a 120 s recovery lease; attempts increment before networking and each HTTP attempt is bounded to 45 s. Failures retry after 60/120/240/480 s (5 attempts total) then become DEAD_LETTER. Delivery states: PENDING, DELIVERING, RETRY, DELIVERED, DEAD_LETTER, CANCELLED. Deleted/inactive watches cancel queued work; expired leases recover after process failure. Delivery is at least once: receivers must deduplicate stable event IDs. Envelope content/createdAt remain stable; timestamp/signature refresh per attempt. At most 20 due events run before/after watch checks. No automatic dead-letter replay API is included. Event types: identity.document.changed, identity.domain_binding.changed, identity.deactivated, identity.resolution.failed, identity.resolution.recovered. data carries fromHash, toHash, domainBinding { from, to }, a diff summary and the verdict headline (or the problem for failures).

Headers:

X-Didis-Event: identity.document.changed
X-Didis-Event-Id: evt_123
X-Didis-Timestamp: 1790000000
X-Didis-Signature-256: sha256=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>

Receivers should recompute the HMAC over the raw body, compare in constant time and reject timestamps older than a few minutes (verifyWebhookSignature / verify_webhook_signature in the SDKs use 300 s).

GET /health → { status: "ok", name, version, time, supportedMethods, standards: { … }, monitoring: bool, rateLimited: bool } (liveness).

GET /ready → { ready: true, storage: file | memory, schema: current }, or 503 NOT_READY with Retry-After: 5 when storage/schema is unavailable. It does not certify write durability, disk capacity, backups, or external-service health. Both probes bypass customer rate buckets. BFF/SSR/browser and SDK errors preserve Retry-After metadata as retryAfter.

Startup: DIDIS_ENV=production requires a file database; configured initialization failures abort the daemon and CLI. Future-schema databases are not downgraded. Schema v3 introduced monitor revisions, fingerprint profiles, delivery lease and due-time fields. Schema v4 introduced tenant/project/key/plan/usage groundwork; schema v5 added offline mock billing link/inbox/entitlement/ledger; current schema v8 also includes customer Monitor scheduling/events and customer webhook state transactionally. See mock-only reconciliation and rollback limitations. Explicitly opt-in local operator/project-key/session APIs now connect a narrow private resolve to atomic accounting; these are absent from the public BFF, and there is no payment activation. See local API routes and limits. See commercial groundwork and remaining gates. Backup: didis --db <source> backup <new-destination> uses a consistent SQLite snapshot, never overwrites a file, and creates mode 0600 on Unix. Backups contain webhook secrets and require protected/encrypted storage. See single-server operations.

Opt-in owner-session routes now support project key metadata/issuance (GET|POST /v1/session/projects/{project}/keys), revoke (DELETE .../keys/{key}), and atomic rotation (POST .../keys/{key}/rotate). The cookie BFF exposes only these exact methods, requires configured origin for all writes, derives subject from the maintained database session, and strips client role/subject/Authorization headers. All owner actions revalidate ownership inside their storage transaction. Operator and owner issuance share 10-active / 100-retained per-project safety caps without deleting history. Capacity failure returns 429 KEY_CAPACITY_EXHAUSTED; it is not transient capacity replenished by Retry-After. Rotation failures leave the old key authorized. Revocation remains available. Metadata never returns raw secrets or digests. The account panel displays secrets once in memory, hides them after 60 seconds/page hiding/project switch/signout and performs no automatic copy/retry. See LOCAL_CUSTOMER_PILOT.md for limitations. Customer Monitor and connected provider activation remain incomplete.

Local customer Monitor pilot (opt-in, partial)

Section titled “Local customer Monitor pilot (opt-in, partial)”

Maintained owner session+private bridge routes now include GET/POST projects/{project}/watches, DELETE watches/{watch}, GET scheduled-usage and GET watches/{watch}/events under /v1/session. Public BFF cannot expose these; cookie customer BFF only forwards the exact reviewed paths with session/origin validation. Create body is strict {did}; supported key/jwk/web, current Monitor required. GET creation/list disclose whether automatic local worker is enabled. Event view returns owner-private events from the latest30 days, maximum200; larger pages refuse instead of truncating. Later bounded report and signed delivery routes are defined below; no raw baseline/all-time archive or real provider grant is claimed.

Schema v7 added private monitor baselines/events after schema-v6 scheduler; current schema v8 adds customer webhook configurations/outbox. Old executables require paired pre-upgrade snapshots for approved rollback, not user_version edits. DIDIS_LOCAL_CUSTOMER_MONITOR=true additionally requires DIDIS_LOCAL_COMMERCIAL=true/file DB and activates a bounded local30-second poller of hourly due work. This is scheduled observation, not realtime detection. Separate18000 rolling30-day attempt limit counts failures/crashes and never consumes API operation allowance. See CUSTOMER_MONITOR_SCHEDULING.md for exact budgets, private resolution, atomic transition/fence and delivery/report contracts and remaining activation gates.

Private owner event export pages are now POST /v1/session/projects/{project}/watches/{watch}/event-page, body {limit, asOf?, snapshotEventId?, beforeEventId?}. limit is1..100; UI uses50. Strict fields, per-page current ownership and30-day visibility, highest inserted-event opaque snapshot watermark, persistence-order paging, bounded serialized-event bytes and explicit hasMore/next cursor. GET on this route is not supported, cursor query strings remain forbidden, and public BFF cannot forward it. Customer cookie BFF uses exact POST+session/origin policy. UI downloads only explicitly confirmed current JSON page; not a combined full archive, retained raw baseline/report or paid activation. The separate bounded complete report route is described below. See CUSTOMER_MONITOR_SCHEDULING.md for semantics and limitations.

Current customer signed delivery and complete report

Section titled “Current customer signed delivery and complete report”
  • GET/POST/DELETE /v1/session/projects/{project}/webhook: current-owner metadata, strict {url} Monitor-gated setup/rotation (201 {config,secret}, generated secret once), and disable/cancel. No caller-provided secret, backfill or secret read route. Protected SQLite signing storage and narrow HTTPS URL restrictions apply. GET returns {config,workerActive}; reassigned ownership cannot continue the old owner’s receiver.
  • GET /v1/session/projects/{project}/webhook-deliveries: newest100 metadata rows in current30-day window, not a complete archive; no body, signature, secret, lease or receiver response.
  • POST /v1/session/projects/{project}/watches/{watch}/report: strict {}; complete single-watch current30-day read snapshot or HTTP409 MONITOR_REPORT_CAPACITY at >1000 rows / >8 MiB, never partial. Includes schedule metadata/events/limits/scope/limitations, no raw baseline or stored server file. The cookie BFF requires exact methods, maintained session and origin; public BFF and service keys cannot invoke owner control.

Customer headers are X-DIDIS-Event-ID and X-DIDIS-Signature: t=<creationTime>,v1=<hex HMAC-SHA256>. HMAC signs creationTime + . + exact UTF-8 body; retries preserve bytes/ID/signature. This is separate from the admin X-Didis-Signature-256/fresh-request-time SDK helper above. Receivers use the new customer SDK verifier with explicit owner/event binding and durable deduplication. See CUSTOMER_WEBHOOK_DELIVERY.md for the at-least-once, five-attempt, cancellation/in-flight and protected-plaintext-secret contract. No API quota or billing authority comes from a webhook signature.