# Digitalyser.io for developers (/docs) Digitalyser.io exposes one contract in two shapes. Agents speak **MCP**; systems speak **REST**. Both read the same capability registry, resolve the same permissions, and return the same refusals — so what you learn on one transport carries to the other. Claude, Cursor, VS Code and any MCP client connect over OAuth 2.1. No key to copy, one project per connection, and the user chooses what the agent may do. n8n, Make and Zapier talk to the REST facade with a scoped key. Importable workflow templates get you to a first result without writing a backend. Capabilities, effects, proposals and audiences. Six concepts, and the rest follows. Three exist. Only one is right for your case, and choosing wrong is the most common way to lose an afternoon. ## What you can do today [#what-you-can-do-today] The registry describes what the product knows how to do — audits, content, editorial strategy, brand voice, local visibility. Every capability declares its **effect**, and that declaration is the whole safety model: | Effect | What it means | How you call it | | --------- | --------------------------------------------------------- | ------------------------------------------------------- | | `read` | Returns data. Changes nothing. | Directly | | `propose` | Computes a change and returns a diff. Writes nothing yet. | Directly, then confirm | | `write` | Applies a diff a human already saw. | Never directly — only through the proposal it came from | A `write` capability is not listed and not callable. That is not an omission: it is the reason an agent cannot modify your data without someone seeing the change first. ## Access [#access] The API, the MCP endpoint and outbound webhooks are part of the **Enterprise Agency** plan. Every page here is public — read it, evaluate it, and try it against the demo project before you talk to anyone. A read-only demo project — no account, frozen data, real response shapes — is on the way. Until it ships, the [quickstart](/docs/guides/quickstart) walks the parts that need no credential at all: protocol discovery, and the exact refusal you get without one. # Cookbook (/docs/cookbook) The reference tells you what every operation returns. It does not tell you what to do when a score is `null`, which of two pagination shapes you are looking at, or which refusals are worth retrying. Each recipe below is a whole task, and each is organised around the wrong turn — because the wrong turn is the part you cannot read off a schema. Every example uses the values declared in the contract itself, so what you see here is what the reference shows. ## Read a project's health without misreading a null [#read-a-projects-health-without-misreading-a-null] ```bash curl -sH "Authorization: Bearer $DGA_TOKEN" \ https://app.digitalyser.io/api/v1/projects/$PROJECT_ID/audits/overview ``` One call gives every module's score plus the comparison radar. Two fields decide whether you may use a number, and **both are absent from a naive read**: ```jsonc { "moduleScores": [ { "moduleId": "local-visibility", "score": null, // not a zero "status": "completed", // the run finished anyway "scoreAvailabilityState": "suppressed-incomplete-coverage" } ], "radar": { "competitorPosture": "High Confidence", // about the COHORT "axes": [ { "key": "authority", "you": 61, "competitorAvg": null, "competitorAvgReason": "no-competitor-measured-this-pillar" } ] } } ``` **The wrong turn.** Treating `score: null` as `0`, and reading `competitorPosture: "High Confidence"` as a promise that every axis has a comparison. Neither holds: | you see | what it means | what to do | | ---------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------- | | `score: null` | the score was not produced | read `scoreAvailabilityState`; show "not measured", never `0` | | `status: "completed"` with `score: null` | the run finished, the score did not | the two are independent | | `competitorAvg: null` | no retained competitor measured **this pillar** | read `competitorAvgReason` | | `competitorPosture` | a verdict on the cohort's coverage and freshness | never read it per axis | A dashboard that averages `null` as zero will report a collapse that has not happened. See [glossary](/docs/guides/glossary#score). ## Change something: propose, review, apply [#change-something-propose-review-apply] There is no single-call write. The `propose` half computes a diff; a second, explicit call commits it. Four pairs exist today — refining personas, refining clusters, curating the competitor register, and adding a competitor. Curating the register, end to end: ```bash # 1 — propose. Nothing is written. curl -sX POST -H "Authorization: Bearer $DGA_TOKEN" -H 'Content-Type: application/json' \ https://app.digitalyser.io/api/v1/capabilities/audit.competitors.curate.propose/invoke \ -d '{"action":"exclude","competitorEntryIds":["…uuid…"]}' ``` ```jsonc { "proposalId": "…", // an opaque handle, not a right "diff": { /* what would change, masked by plan */ }, "applyCapabilityId": "audit.competitors.curate.apply", "expiresAt": "2026-09-10T18:42:00.000Z" // a proposal has a lifetime } ``` ```bash # 2 — apply. The diff is RECOMPUTED and compared before anything is written. curl -sX POST -H "Authorization: Bearer $DGA_TOKEN" \ https://app.digitalyser.io/api/v1/proposals/$PROPOSAL_ID/apply ``` **The wrong turn.** Treating `409 diff_stale` as a failure to retry blindly. It means someone — a person, or another agent — changed the target between your two calls, and applying now would silently discard their change. Propose again, look at the fresh diff, then apply. Five other refusals live on this path, all `409` except the last: | `reason` | when | | --------------------------- | ------------------------------------------------------------------ | | `diff_forbidden` | the proposal targets something it may not change | | `expired` | you waited past `expiresAt` — propose again | | `not_pending` | that proposal was already applied; a proposal is applied **once** | | `not_found` | no proposal by that handle | | `invalid_handle` | the handle is not usable by this credential, project or capability | | `diff_unsafe_content` (422) | the outgoing content itself was refused | The [flow diagram](/docs/guides/flows#a-change-is-proposed-then-applied) draws the two outcomes that matter most. Note `expiresAt`: a proposal is not a ticket you may keep — hold it too long and you recompute. Omitting `competitorEntryIds` is **not** an error — it returns a questionnaire for the person to fill in. Some capabilities are designed to ask rather than assume; a client that treats a questionnaire as a failure will loop. ## Page through everything — and notice there are two shapes [#page-through-everything--and-notice-there-are-two-shapes] Both list operations paginate. They do not paginate the same way, and nothing in their names says so. ```jsonc // GET /v1/projects/{projectId}/contents { "contents": [ /* … */ ], "pagination": { "page": 1, "pageSize": 20, "totalItems": 137, "totalPages": 7 } } ``` ```jsonc // GET /v1/projects/{projectId}/topics { "items": [ /* … */ ], "total": 84, "page": 1, "pageSize": 20, "totalPages": 5, "hasNextPage": true, "hasPreviousPage": false } ``` **The wrong turn.** Writing one pager against `pagination.totalPages` and reusing it for topics, where the counters sit at the **root** and are named differently. A loop that reads `response.pagination.totalPages` on `/topics` gets `undefined`, treats it as `0`, and fetches exactly one page — silently, with no error. Derive "is there a next page" from what each response actually gives you: ```js const hasNext = body.hasNextPage ?? (body.pagination ? body.pagination.page < body.pagination.totalPages : false); ``` ## Survive refusals: what to retry, and what never to [#survive-refusals-what-to-retry-and-what-never-to] Every refusal is an [RFC 9457](https://datatracker.ietf.org/doc/html/rfc9457) problem document. Branch on `reason`, never on the prose: ```jsonc { "type": "https://developers.digitalyser.io/docs/reference/errors#rate_limited", "title": "Too many requests", "status": 429, "reason": "rate_limited" } ``` | status | `reason` | retry? | | ------ | ------------------------- | ----------------------------------------------------------------------- | | 429 | `rate_limited` | **yes**, after `Retry-After` seconds — never sooner, never immediately | | 429 | `quota_exceeded` | **no**. A plan ceiling, not a burst; retrying changes nothing | | 403 | `capability_not_allowed` | **no**. Outside the consent grant — the person must re-consent | | 403 | `insufficient_permission` | **no**. The bearer lacks the permission; an administrator must grant it | | 403 | `routing_hint_mismatch` | **no**. The `{projectId}` in your path is not the token's project | | 401 | `credential_invalid` | **no**, not as-is. Follow `WWW-Authenticate` to re-authorise | | 409 | `diff_stale` | **not as-is** — recompute the proposal first | | 409 | `expired`, `not_pending` | **no**. Propose again; a proposal is single-use and short-lived | **The wrong turn.** A blanket "retry on 4xx with backoff". Two of these statuses cover refusals that will never change on their own: retrying `quota_exceeded` burns your rate limit to receive the same answer, and retrying `capability_not_allowed` never obtains a consent nobody gave. Read your remaining budget from the response instead of guessing, using the [rate-limit headers](/docs/guides/limits): ``` RateLimit-Policy: "credential";q=60;w=60, "project";q=120;w=60 RateLimit: "credential";r=58;t=42, "project";r=115;t=42 ``` `r` is what remains, `t` the seconds until the window resets. The full list of refusals is in the [error reference](/docs/reference/errors). # Security and compliance (/docs/compliance) The answers a security review asks for, in one place. ## Reporting a vulnerability [#reporting-a-vulnerability] Our disclosure policy lives at [`/.well-known/security.txt`](https://developers.digitalyser.io/.well-known/security.txt). Report anything you find; we acknowledge within two business days and keep you informed until it closes. We do not pursue researchers acting in good faith under that policy. ## Credentials and revocation [#credentials-and-revocation] Agent access tokens live one hour. Refresh tokens rotate on every use, and a replayed refresh token revokes its whole chain rather than being merely rejected — a stolen token is therefore worth an hour at most, and using it destroys the connection it came from. Any connection can be revoked, and revocation cascades to every token derived from it. ## Isolation [#isolation] A connection carries exactly one project. Nothing in a request can change it. Capabilities resolve permissions on every call from the membership in the database — not from a claim frozen in a token — so a role change takes effect on the next request rather than at the next login. ## Data and models [#data-and-models] Parts of the product send content to large language models. What we commit to: * your data is **not used to train** any model; * retention is bounded and encrypted at rest; * transfers outside the European Union are declared, with their legal basis, in the sub-processor list. This page is being completed alongside the API terms and the sub-processor list. Where a commitment is not yet published here, it is because it is being verified against the underlying contracts rather than asserted — a public commitment we cannot honour would be worse than silence. ## What we do not claim [#what-we-do-not-claim] We hold no SOC 2 and no ISO 27001 certification today. Saying so plainly is more useful to your review than a page that stays quiet about it. What we can show is the controls above, and how they are enforced in code. # Conventions (/docs/guides/conventions) Read this once and the rest of the reference reads itself. ## The two transports refuse differently [#the-two-transports-refuse-differently] Same cause, two shapes — and this trips up anyone who reads the MCP guide and then writes REST. | | MCP | REST | | ------------------------ | ------------------------------------ | ----------------------------- | | Transport-level refusal | `401` with `WWW-Authenticate` | `401` with `WWW-Authenticate` | | Capability-level refusal | `200` with a JSON-RPC error envelope | Real HTTP status code | Past the transport guard, the MCP layer wraps every error in a `200` JSON-RPC response — that is the protocol, not a choice. Do not branch on the HTTP status alone when speaking MCP: read the envelope. One deliberate exception: a capability outside your granted scope is refused **before** the transport, as a real `403 insufficient_scope` with a `WWW-Authenticate` challenge. It has to be, because a refusal buried in a `200` is one no client can read as an invitation to re-authorise. ## Refusals are typed [#refusals-are-typed] A refusal tells you which of four things went wrong: the capability does not exist, your credential may not use it, the request is malformed, or a precondition failed. Refusals are stable and enumerable — the full list is generated from the registry itself, so it cannot drift from what the server does. ## Identifiers [#identifiers] Capability identifiers are dotted, lowercase, and stable: `audit.overview.get`, `content.items.list`, `brand-voice.define`. The last segment is the verb. Resource identifiers are UUIDs. Neither is ever recycled. ## Pagination [#pagination] Two reads paginate, and **they do not use the same envelope**. We would rather tell you than let you find out from a second parser. | | `GET /v1/projects/{projectId}/topics` | `GET /v1/projects/{projectId}/contents` | | --------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------ | | The page | `items` | `contents` | | Where the counts live | at the root: `total`, `page`, `pageSize`, `totalPages` | in a `pagination` object: `page`, `pageSize`, `totalPages`, `totalItems` | | The total is called | `total` | `totalItems` | | Is there a next page? | `hasNextPage` and `hasPreviousPage` | not given — compare `page` with `totalPages` | Both take the same two query parameters. `page` starts at 1 and defaults to 1. `pageSize` defaults to 20 and is capped at 50: ask for more and you get 50, not a refusal. Every other read returns its whole document and takes neither. To walk a list, request page 1, then keep going until `hasNextPage` is `false` on topics, or until `page` reaches `totalPages` on contents. Do not assume the last page is full. ## Replaying a write [#replaying-a-write] There is no `Idempotency-Key` header, and we will not pretend otherwise. What the API guarantees instead is narrower and lives in the state of the change itself: a proposal is **applied once**. Apply it a second time — a retry after a timeout, a double click — and you get `409` with [`not_pending`](/docs/reference/errors#not_pending), which means *nothing to retry*: the proposal was already applied, or it was discarded. Read the current state before proposing again. Reads are safe to repeat. For the `5xx` failures, retry with backoff, as the [error catalogue](/docs/reference/errors) says on each of those entries. ## Timestamps [#timestamps] Every timestamp is UTC, ISO 8601, with an explicit offset. There are no naive timestamps in the API surface. ## Rate limits [#rate-limits] Requests are bounded per credential and per project. We publish no numbers here — a limit written in documentation becomes a promise, and we only promise what the code enforces. When limits are enforced at the edge you will see them announced in standard `RateLimit-*` response headers, and this page will say so. ## One project per connection [#one-project-per-connection] Not a convention — an invariant. A session carries exactly one project, resolved from the credential. No parameter can change it, and passing a project identifier in the body of a call is refused rather than ignored. An agency opens one connection per client. # Choose your credential (/docs/guides/credentials) Digitalyser.io issues three kinds of credential. They are not interchangeable, and the failure mode when you pick wrong is quiet: the call authenticates, then refuses. ## The short answer [#the-short-answer] | You are… | Use | Looks like | | ---------------------------------------------------------- | ---------------------------------- | ---------- | | An AI client acting for a person (Claude, ChatGPT, Cursor) | **Agent token**, via OAuth consent | `dga_…` | | A server, script, or automation platform (n8n, Make, cron) | **Derived project token** | `dga_…` | | The dashboard, in a browser session | Session JWT — not for you | — | | Calling the REST surface *without* capabilities | Scoped API key | `rk_…` | **A scoped API key opens no capability.** This is the single most common wrong turn. An `rk_` key authenticates the transport, and that is all it does — capabilities refuse it outright. The reason is structural, not a missing feature: **a capability derives its project from the credential**, and an API key is scoped to an organisation, not to a project. Honouring it would mean taking the project from a parameter, which is exactly the tenant-isolation hole the whole model exists to close. ## Agent token — `dga_` [#agent-token--dga_] The only credential that opens capabilities. It always carries exactly one project. There are two ways to get one, and they differ only in who is present. ### With a person — OAuth consent [#with-a-person--oauth-consent] For AI clients. Obtained by the client, never by copy-paste: the user starts the connection from their tool, lands on a consent screen, picks **one project**, and ticks what the agent may do. Access tokens last an hour; a rotating refresh token (`dgr_`) keeps the connection alive for thirty days. ### Without a person — derived project token [#without-a-person--derived-project-token] For servers, schedulers and automation platforms. No browser, no consent screen. 1. An organisation administrator creates a **service key** for the organisation. 2. They derive **one token per project** from it. The secret is returned once, at creation. 3. Your system sends that token. ```bash curl -X POST \ https://app.digitalyser.io/api/organizations/$ORG/agent-service-keys/$KEY/tokens \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"projectId":"…"}' ``` Revoking the service key revokes every token derived from it, in one gesture. ### Sending it [#sending-it] `Authorization: Bearer dga_…`, or the `x-agent-token` header. One token, one project — for machines exactly as for people. An agency following several brands derives one token per client, so a workflow wired for one client cannot reach another's data. That uniformity is deliberate: there is no "machine mode" with weaker isolation. ## Scoped API key — `rk_` [#scoped-api-key--rk_] Created by an organisation administrator, scoped at issue time from the permission catalogue. It authenticates the REST surface for endpoints that are not capabilities. It carries only the permissions it was given, and — as the warning above says — it opens no capability. Send it as `Authorization: Bearer rk_…`. A key in a browser is a leaked key. The REST surface is server-to-server only and does not enable permissive CORS — call it from your backend, never from a page. ## Session JWT [#session-jwt] Issued by the dashboard for a signed-in browser session. It authenticates the transport but opens no capability: presenting one to the MCP endpoint returns `401` with a message saying exactly that. If you are reading this page, this is not the credential you want. ## What a wrong choice looks like [#what-a-wrong-choice-looks-like] Present anything the MCP endpoint does not recognise and you get: ```http HTTP/2 401 WWW-Authenticate: Bearer error="invalid_token", resource_metadata="https://app.digitalyser.io/.well-known/oauth-protected-resource/api/mcp" { "message": "Unrecognized credential for the MCP endpoint. Capabilities require an agent project token — send it as \"Authorization: Bearer dga_…\" or in the \"x-agent-token\" header. A JWT or API key authenticates the transport but opens no capability.", "error": "Unauthorized", "statusCode": 401 } ``` The `WWW-Authenticate` header is the useful part: it points at the resource metadata your client needs to start the authorisation flow. A conforming MCP client reads it and re-authorises on its own. # Flows (/docs/guides/flows) Two mechanisms carry most of the misunderstandings about this API: how a token appears without anyone pasting one, and why a write is two calls instead of one. Both are drawn here. The diagrams are inline SVG — no runtime, and they follow the site's theme. ## An AI client obtains a token [#an-ai-client-obtains-a-token] Nobody types a token into Claude or Cursor. The client discovers where to ask, registers itself, and the person consents in a browser. What comes back is scoped to **one project** and to the capabilities that were ticked.
AI client Digitalyser.io 1 call /api/mcp — no token 2 401 + WWW-Authenticate: resource_metadata=… 3 GET .well-known/oauth-protected-resource then .well-known/oauth-authorization-server 4 POST /register — dynamic client registration 5 GET /authorize — browser opens A person picks ONE project and ticks what the agent may do 6 POST /token — code + PKCE verifier 7 agent token — dga_…, one project, scoped
Steps 3 and 4 are why nothing has to be configured by hand: the resource says where its authorization server is ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)), and the client registers itself rather than being pre-provisioned. Step 5 is the only human moment. What the token is **not**: an account-wide key. It carries one project, and the capabilities consented to — see [credentials](/docs/guides/credentials) for the three credential kinds and [glossary](/docs/guides/glossary#credential) for why a key that authenticates opens nothing on its own. ## A change is proposed, then applied [#a-change-is-proposed-then-applied] There is no single-call write. A `propose` capability computes a diff and hands it back; applying it is a second, explicit call — and that call **recomputes the diff before writing anything**.
invoke a propose capability POST /v1/capabilities/…/invoke a proposal + its diff nothing written yet a human or agent reviews it apply — the diff is RECOMPUTED {'POST /v1/proposals/{id}/apply'} differs refused 409 diff_stale identical committed, transactionally applied once, with a row lock recompute and review the fresh diff
`diff_stale` is the most common refusal on this path, and it is **a safety net, not a fault**: someone — a person, or another agent — changed the target between the proposal and the apply, and applying now would silently discard their change. Recompute, review, apply again. Two other refusals live here: `diff_forbidden` (409) when the proposal targets something it may not change, and `diff_unsafe_content` (422) when the outgoing content itself is refused. All three are in the [error reference](/docs/reference/errors). Publishing to an external network is never part of this. No capability declares it — it stays a human gesture, and no token can make it one of ours. # Glossary (/docs/guides/glossary) Most of these terms you can guess. A few you cannot, and those are the reason this page exists: each one has misled someone reading a payload — a word that names two different quantities, a `null` that looks like a zero, a verdict that qualifies something other than what it sits next to. ## Capability [#capability] The unit of authorisation. Every call — REST, MCP or chat — resolves to a capability, and the capability carries its own permission check, plan check, output masking and audit trail. There is no second path to the same data. Each capability declares an **effect**: | effect | what it does | callable directly | | --------- | ------------------------------------------- | ----------------- | | `read` | returns data, changes nothing | yes | | `propose` | computes a change and returns it for review | yes | | `write` | applies a reviewed change | **no** | A `write` capability is never listed and never directly callable. It is reached only through the proposal it belongs to — listing it would invite a client to try. ## Proposal [#proposal] The two-step shape of every change. A `propose` capability returns a diff and an identifier; nothing has happened yet. `POST /v1/proposals/{proposalId}/apply` is what commits it, and it recomputes the diff before applying — a proposal that no longer matches the current state is refused as `diff_stale` rather than applied blindly. ## Surface [#surface] Where a call arrives from: `api` (REST), `mcp`, or `dashboard`. The same capability publishes **different fields per surface**. A field you see in the dashboard is not necessarily a field this API returns; what `api` publishes is declared in the OpenAPI document and nothing else comes out. ## Content item, scored item [#content-item-scored-item] Two counts, one word, and they are routinely different. * `totalItems` — every content item the project has. * `scoredItems` — those that carry a numeric score. **`avgScore` is an average over `scoredItems`, never over `totalItems`.** A project can hold sixty content items with none scored: `totalItems: 60`, `scoredItems: 0`, and `avgScore: null`. Dividing by `totalItems` yourself would report a collapse that has not happened. ## Score [#score] Always out of 100, and `null` is never a zero. `null` means the score was not produced — read `scoreAvailabilityState` on the module to learn why. `suppressed-incomplete-coverage` says the module ran but measured too little to score honestly; `stale-last-known` says the number is real but comes from an earlier run. A module whose `status` is `completed` can still carry `score: null`: the run finished, the score did not. ## Freshness — two clocks [#freshness--two-clocks] This is the one that catches people. | field | measures | horizon | | ---------------------------------------- | ------------------------------------------- | ----------- | | `assetFreshness` on a competitor | crawl assets used for editorial inspiration | about a day | | the freshness behind `competitorPosture` | audit runs across the cohort | thirty days | A competitor marked `stale` under `assetFreshness` **does not contradict** a `High Confidence` cohort. They measure different things on different horizons. The field was named `assetFreshness` rather than something generic precisely so the two would stop sharing a word. ## Posture, coverage, and a null average [#posture-coverage-and-a-null-average] `competitorPosture` and `competitorCoverage` are verdicts on the **cohort** — how many retained competitors were measured, and how recently. They never qualify an individual radar axis. So a `High Confidence` cohort can sit next to five axes whose `competitorAvg` is `null`, and nothing is wrong. `competitorAvgReason` says which of the two causes applies: * `cohort-below-minimum` — the cohort itself is under the retention threshold; * `no-competitor-measured-this-pillar` — the cohort is sufficient, but no retained competitor measured this particular pillar. ## Credential [#credential] A `dga_` bearer token, scoped to **one project**. It is not an account-wide API key: it opens the capabilities that project's plan and the bearer's permissions allow, and nothing beyond. See [credentials](/docs/guides/credentials) for how to obtain one. ## Routing hint [#routing-hint] The `{projectId}` segment in every path. It exists so multi-project code stays uniform — **it authorises nothing.** The project comes from the credential. If the segment names a project outside the token, the call is refused with `routing_hint_mismatch`; it is never used to widen access. ## Refusal [#refusal] A call that was understood and declined, returned as an RFC 9457 problem document. The `type` URI dereferences to the [error reference](/docs/reference/errors), and the `reason` is the stable, machine-readable field — branch on it, not on the prose. A refusal is not an outage. ## Plan gating [#plan-gating] Capabilities a plan does not include are **listed and marked as such**, not hidden. Gating happens when a connection is authorised, not when you read a page: you should be able to see what you would get before you buy it. ## Search intent [#search-intent] The `intent` field on content and topics carries **French values**, including one with a diacritic: ``` informationnelle · commerciale · éducationnelle · inspirationnelle transactionnelle · navigationnelle · locale · unclassified ``` They are stored values, not display labels, and they are stable — comparing against `'informational'` will never match. Normalise or map them on your side; branch on the exact strings above, `éducationnelle` included. # Limits and quotas (/docs/guides/limits) **A rate limit and a quota both return `429`, and they mean opposite things.** One resets within the minute; the other resets with the calendar month. Retrying is right for the first and useless for the second — the `reason` field tells you which one you hit. ## The two mechanisms [#the-two-mechanisms] | | Rate limit | Quota | | -------------- | ---------------------------- | ------------------------------------------------------- | | `reason` | `rate_limited` | `quota_exceeded` | | Counts | requests | **work performed** — audits run, strategies regenerated | | Window | 60 seconds, fixed | the calendar month, UTC | | Resets | within the minute | on the first of the month | | Retrying helps | **yes**, after `Retry-After` | **no** | ## Rate limit [#rate-limit] Two counters run at once, and both must pass. **Per credential** — 60 requests a minute by default. This is the number attached to your token; a plan can raise it. **Per project** — 120 requests a minute, whatever the number of tokens. It exists so several credentials pointing at the same project cannot saturate it between them. An agency running one token per client is not affected; a single client running four tokens can be. ### The response tells you where you stand [#the-response-tells-you-where-you-stand] Every authenticated call carries the state of both counters, following [the IETF RateLimit header fields](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/): ```http RateLimit-Policy: "credential";q=60;w=60, "project";q=120;w=60 RateLimit: "credential";r=42;t=17, "project";r=98;t=17 ``` `q` is the quota, `w` the window in seconds, `r` what remains, `t` the seconds until the counter resets. **`r` never goes below zero** — exhausted reads as `r=0`, not as a negative number. The quota shown is **yours**. It is the limit carried by the credential you presented, not the ceiling of some other plan. ### On a refusal [#on-a-refusal] ```http HTTP/2 429 Retry-After: 17 Content-Type: application/problem+json { "type": "…/reference/errors#rate_limited", "status": 429, "reason": "rate_limited" } ``` **Wait for `Retry-After`, do not guess.** Guessing goes wrong in both directions: too short and you walk straight back into the wall, too long and you give up throughput you were entitled to. `Retry-After` is the header to branch on. The `RateLimit` fields are still an IETF draft; this one has been a standard HTTP header from the start, and every client library already understands it. ### The counter is shared across transports [#the-counter-is-shared-across-transports] The same credential is counted once, whether it calls through MCP or through `/v1`. Splitting your traffic between the two buys you nothing — which is deliberate: the limit belongs to the credential, not to the door it walks through. ## Quota [#quota] A quota counts **work performed**, not requests. Reading is never quota'd; only the capabilities that start real work are. Two of them carry a quota today: * **`audit.run.request`** — running an audit * **`editorial.strategy.regenerate.request`** — regenerating the editorial strategy Both are marked `writesOnInvoke` in the catalogue: **invoking them already starts the work and consumes the quota.** There is no dry run. Ask before calling one, not after. The counter is **per project** and resets at the start of each calendar month, UTC. The ceiling comes from the plan. ### Ceilings by plan [#ceilings-by-plan] These are the ceilings the API and MCP enforce today, generated from the platform configuration — not typed by hand. A `0` means the plan does not include that capability. **There is no header for the remaining quota.** Rate limits are observable before you hit them; quotas are not — you learn the ceiling by reaching it, or from the plan. If your integration needs to know in advance, say so: it is a gap we know about, not an oversight. ## Telling them apart in code [#telling-them-apart-in-code] Branch on `reason`, never on the status code alone: ```js if (res.status === 429) { const problem = await res.json(); if (problem.reason === 'rate_limited') { // Transient. Wait exactly what the server asked for. await sleep(Number(res.headers.get('Retry-After')) * 1000); return retry(); } // quota_exceeded — retrying this month changes nothing. return surfaceToUser(problem); } ``` Treating `quota_exceeded` as transient produces a client that retries until the first of the month. That is the failure this page exists to prevent. # Quickstart — agency (/docs/guides/quickstart-agency) You follow several brands. The model is one connection per project: a token carries exactly one project, for machines as for people. ## 1. One service key, one token per client [#1-one-service-key-one-token-per-client] An organisation administrator creates a **service key** once, then derives a token per client project (the call is in [Choose your credential](/docs/guides/credentials)). Revoking the service key revokes every derived token in one gesture. ```bash curl -X POST \ https://app.digitalyser.io/api/organizations/$ORG/agent-service-keys/$KEY/tokens \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"projectId":""}' ``` The secret is returned once. Store each token under the client's name in your vault. ## 2. Read each client's audit overview [#2-read-each-clients-audit-overview] ```bash curl https://app.digitalyser.io/api/v1/projects/$CLIENT_PROJECT_ID/audits/overview \ -H "Authorization: Bearer $CLIENT_TOKEN" ``` Loop over your clients with their own token each time. There is no cross-project token, by design. ## 3. Mind the ceilings [#3-mind-the-ceilings] Quotas are **per project** and reset on the first of the calendar month, UTC; your plan sets the ceilings (see [Ceilings by plan](/docs/guides/limits)). One client exhausting theirs does not touch another's. ## 4. People in the loop [#4-people-in-the-loop] For a human who wants to drive one client from an assistant, use the [agent quickstart](/docs/guides/quickstart-agent): the consent screen lets them pick that client's project. ## Next [#next] * [Limits and quotas](/docs/guides/limits) * [Conventions](/docs/guides/conventions) # Quickstart — AI agent (/docs/guides/quickstart-agent) You are wiring an assistant (Claude, Cursor, VS Code, LangGraph) that acts for a person. About five minutes, and you copy no secret. ## 1. Connect [#1-connect] ```bash claude mcp add --transport http digitalyser https://app.digitalyser.io/api/mcp ``` Other clients: add a remote MCP server at `https://app.digitalyser.io/api/mcp`. See [Connect Claude](/docs/integrations/claude) for Desktop and claude.ai. The first call opens a consent screen. Pick **one project** and tick what the agent may do — the token it receives carries exactly that. ## 2. Ask for what it can do [#2-ask-for-what-it-can-do] Ask the assistant: *"List the Digitalyser.io capabilities you can use."* It calls `listCapabilities` and answers with the catalogue **for this credential**. A capability you did not tick is absent, not refused. ## 3. Read something [#3-read-something] Ask: *"Give me the audit overview for my project."* Reads change nothing, so this is the safe first call. ## 4. Make it write — and see the guard [#4-make-it-write--and-see-the-guard] Ask the assistant to regenerate the editorial strategy. It gets back a **proposal** and a diff, shows you, and only `applyProposal` writes — after the server recomputes the diff and compares it with the one you saw. Some capabilities start real work as soon as they are invoked and consume your monthly quota. The catalogue marks them; see [Limits and quotas](/docs/guides/limits). ## Next [#next] * [Conventions](/docs/guides/conventions) — errors, pagination, replaying a write. * [Choose your credential](/docs/guides/credentials) — why an agent never uses an API key. # Quickstart — no-code integrator (/docs/guides/quickstart-integrator) You are automating on a schedule, with nobody watching: n8n, Make, Zapier, cron. You want a status code and the same answer every time. ## 1. Get a token [#1-get-a-token] An organisation administrator derives a **project token** from a service key (no browser step) — the exact call is in [Choose your credential](/docs/guides/credentials). The secret is shown once; store it in your platform's credential vault. Do not use a scoped API key (`rk_…`): it opens no capability. Use the derived token (`dga_…`). ## 2. Make a first call [#2-make-a-first-call] ```bash curl https://app.digitalyser.io/api/v1/capabilities \ -H "Authorization: Bearer $DIGITALYSER_TOKEN" ``` `200` with the catalogue this token may use. `401` means the token is wrong or revoked. ## 3. Read an audit [#3-read-an-audit] ```bash curl https://app.digitalyser.io/api/v1/projects/$PROJECT_ID/audits/overview \ -H "Authorization: Bearer $DIGITALYSER_TOKEN" ``` `$PROJECT_ID` is the project the token was derived for. Every read endpoint is in the [API reference](/docs/reference). ## 4. In n8n [#4-in-n8n] Use an **HTTP Request** node, not the MCP Client Tool node (it requires SSE, which is not served — see [Integrations](/docs/integrations)). Set the URL above, authentication to *Header Auth* with `Authorization: Bearer `, and store the token as a credential. ## 5. Handle the two `429`s [#5-handle-the-two-429s] Branch on `reason`, never on the status: `rate_limited` is worth retrying after `Retry-After`; `quota_exceeded` is not. Details in [Limits and quotas](/docs/guides/limits). ## Next [#next] * [Conventions](/docs/guides/conventions) — pagination and replaying a write. * [Cookbook](/docs/cookbook) — worked recipes. # Quickstart (/docs/guides/quickstart) Pick the path that matches what you are building. All three start with calls you can make right now, without an account. ## Step 1 — Prove the endpoint is there [#step-1--prove-the-endpoint-is-there] Protocol discovery is public. These two calls need no credential and tell you everything a client needs to authorise itself. ```bash curl https://app.digitalyser.io/.well-known/oauth-protected-resource/api/mcp ``` ```json { "resource": "https://app.digitalyser.io/api/mcp", "authorization_servers": ["https://app.digitalyser.io"] } ``` ```bash curl https://app.digitalyser.io/.well-known/oauth-authorization-server ``` ```json { "authorization_endpoint": "https://app.digitalyser.io/api/agent/oauth/authorize", "token_endpoint": "https://app.digitalyser.io/api/agent/oauth/token", "code_challenge_methods_supported": ["S256"] } ``` ## Step 2 — See what a refusal looks like [#step-2--see-what-a-refusal-looks-like] Call the endpoint with a credential it cannot recognise. This is worth doing on purpose: the refusal is the most informative response the API produces, and your client should handle it. ```bash curl -i -X POST https://app.digitalyser.io/api/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'Authorization: Bearer not-a-real-token' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"quickstart","version":"1"}}}' ``` You get `401` with a `WWW-Authenticate` header pointing back at the resource metadata from step 1. That header is the whole handshake: a conforming client reads it and starts the authorisation flow without being told where to go. ## Step 3 — Pick your path [#step-3--pick-your-path] An assistant acting for a person, over MCP and OAuth consent. No key to copy. n8n, Make or a script calling the REST facade with a derived project token. Several client brands: one token per project, so clients stay isolated. ## What comes next [#what-comes-next] * [Choose your credential](/docs/guides/credentials) — the three that exist, and when each is right. * [Conventions](/docs/guides/conventions) — errors, idempotency, and the one place the two transports differ. # Claude (/docs/integrations/claude) Digitalyser.io runs a remote MCP server with an embedded OAuth 2.1 authorization server. Claude discovers it, registers itself, and asks the user what the connection may do. You copy no secret anywhere. ## The endpoint [#the-endpoint] ``` https://app.digitalyser.io/api/mcp ``` Transport is **Streamable HTTP**. SSE is deliberately not served — the July 2026 protocol revision dropped SSE resumption, and under SSE the URL parameter never reaches the POST, which would break project scoping. Any client that requires an SSE endpoint cannot use this server; use the [REST facade](/docs/integrations) instead. ## Claude Code [#claude-code] ```bash claude mcp add --transport http digitalyser https://app.digitalyser.io/api/mcp ``` The first call opens the consent screen in your browser. Pick a project, tick what the agent may do, and you are connected. ## Claude Desktop and claude.ai [#claude-desktop-and-claudeai] Add a custom connector pointing at the same URL. Claude performs dynamic client registration (RFC 7591) and PKCE on its own. If the consent screen warns that the redirect points at a local port on your machine, that is normal for a desktop application. Authorise only if you just started the connection yourself. ## What Claude sees [#what-claude-sees] Three tools, not a hundred: | Tool | Effect | | ------------------ | ------------------------------------------------------------------------------------ | | `listCapabilities` | Returns the catalogue **this credential** may use, each with its JSON Schema | | `invokeCapability` | Runs one. Returns a value, a questionnaire to fill in, or a proposal | | `applyProposal` | Applies a diff the user has seen. The diff is recomputed and compared before writing | The catalogue is deliberately not a fixed tool list. It varies by credential, by plan and by the capabilities the user ticked at consent — a frozen tool list would announce capabilities the server would then refuse, which is a lie one line earlier. ## The write path [#the-write-path] No capability writes on first call. A `propose` returns a `proposalId` and a diff; the user reads it; `applyProposal` then recomputes the diff server-side and compares. A stale diff, a replayed handle, an expired one, or one belonging to another project is refused. Start read-only. The consent screen offers a **Read only** preset — connect with it, ask Claude about your last audit, and widen the scope once you have seen what the tool does with your data. ## Discovery, for client authors [#discovery-for-client-authors] ```bash curl https://app.digitalyser.io/.well-known/oauth-protected-resource/api/mcp curl https://app.digitalyser.io/.well-known/oauth-authorization-server ``` The first is RFC 9728 protected-resource metadata and names the authorization server. The second is RFC 8414 and carries the endpoints, with `S256` as the only supported PKCE method. Both are public. # Documentation MCP server (/docs/integrations/documentation-mcp) This portal runs a small MCP server over its own pages. Point an AI client at it and the assistant can search the documentation and read a page, instead of loading everything or guessing a URL. It is **not** the Digitalyser.io product server. It carries no project data, takes no credential and cannot change anything. To act on a project, use the [product MCP server](/docs/integrations/claude). ## The endpoint [#the-endpoint] ``` https://developers.digitalyser.io/mcp ``` Streamable HTTP, no sign-in. Add it to Claude Code with: ```bash claude mcp add --transport http digitalyser-docs https://developers.digitalyser.io/mcp ``` Other clients: add a remote MCP server with that URL. ## What the assistant sees [#what-the-assistant-sees] Two tools, both read-only: | Tool | Input | Returns | | ------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | | `search_docs` | `query` (2–200 characters), `limit` (1–10, default 5) | The best-matching pages, each with its `title`, its `path` and a short `snippet` | | `get_page` | `path`, as returned by `search_docs` — for example `/docs/guides/limits` | The page as markdown, exactly as served at `/llms.mdx/…/content.md` | A malformed call — a `limit` of 11, a `path` the server does not know — comes back as a tool error that names the field. It is never silently corrected. ## Same content as the site [#same-content-as-the-site] The server reads the pages the site publishes, from the same build. A page you can read here is a page the assistant reads there, and the build refuses to publish if the two differ. If you would rather load everything at once, [`/llms-full.txt`](/llms-full.txt) holds the whole documentation in one file, and [`/llms.txt`](/llms.txt) is its index. ## Good to know [#good-to-know] * **Rate limit.** The endpoint limits each client address. A refused call returns `429` with a `Retry-After` header; wait for it, as for any other [rate limit](/docs/guides/limits). * **Origin.** Requests from a web page on another origin are refused with `403`. Command-line and desktop clients send no `Origin` and are not affected. * **No sessions.** Every call stands alone, so there is nothing to keep alive and nothing to resume. # Integrations (/docs/integrations) The same registry reaches every platform, but each one demands a different shape. This page says plainly which path works for you and what it costs. | Platform | Path | Credential | Status | | --------------------------------------------------------------------------------------------- | ---------- | ---------------------------- | ----------- | | [Claude](/docs/integrations/claude) — Desktop, Code, claude.ai | MCP | OAuth `dga_` | Available | | Cursor, VS Code, LangGraph | MCP | OAuth `dga_` | Available | | n8n | REST `/v1` | Derived project token `dga_` | In progress | | Make, Zapier | REST `/v1` | Derived project token `dga_` | Planned | | ChatGPT | MCP | OAuth 2.1 | Planned | | [Documentation MCP server](/docs/integrations/documentation-mcp) — search and read these docs | MCP | None, read-only | Available | Automation platforms use a **derived project token**, not a scoped API key — an API key opens no capability, because a capability takes its project from the credential and a key carries none. An administrator derives one token per project, without any browser step. See [credentials](/docs/guides/credentials). ## Why n8n goes through REST [#why-n8n-goes-through-rest] n8n's *MCP Client Tool* node connects to an **SSE endpoint**. Digitalyser.io serves Streamable HTTP only, and will not reinstate SSE: the July 2026 protocol revision removed SSE resumption, and under SSE the URL parameter never propagates to the POST — which would silently break the one-project-per- connection guarantee that the whole authorisation model rests on. So n8n talks to the REST facade with an HTTP Request node and a scoped key. That is not a workaround; it is why the facade exists. ## Choosing between MCP and REST [#choosing-between-mcp-and-rest] Use **MCP** when a human is in the loop through an assistant: the agent reads the catalogue, asks what it needs, and brings proposals back for confirmation. Use **REST** when a system acts on a schedule with no one watching: it knows what it wants, it wants a status code, and it wants the same answer every time. Both enforce the same permissions. They differ in one visible way — see [conventions](/docs/guides/conventions) — because MCP wraps every error in a `200` JSON-RPC envelope while REST returns real HTTP status codes. Same cause, two shapes. # Changelog (/docs/operations/changelog) Every change to the public contract appears here, dated, with its category. Deprecations carry their removal date from the day they are announced. Categories: **Added** (safe to ignore), **Changed** (behaviour you may observe), **Fixed** (a documented promise brought back in line), **Deprecated** (twelve-month window opens), **Removed** (window closed), **Security**. Two machine-readable feeds are generated from this page — [RSS](/feed.xml) and [JSON Feed](/changelog.json). Subscribe rather than watching for changes by hand: a deprecation you learn about late is a deprecation you handle badly. ## Unreleased [#unreleased] Nothing pending. ## 2026-09-10 [#2026-09-10] ### Added [#added] * **Response bodies are documented.** Fifteen of eighteen operations now carry a response schema. The three that do not are the capability operations: `invokeCapability` returns the payload of whichever capability was invoked, so it has no fixed shape. * **Eighty-three fields carry a description**, and **forty-five carry an example** — concentrated on what a reader cannot guess: what a `null` score means, which clock a freshness field measures, and why a cohort verdict does not qualify an individual axis. * **Enum values are published** on 128 fields. Where a field accepts a closed set, the contract now lists it instead of declaring a bare `string`. * **The contract is downloadable** at [`/openapi.json`](/openapi.json) — the same document the server publishes, importable into Postman, Insomnia, Bruno or a client generator. * Developer portal published at `developers.digitalyser.io`, with a [cookbook](/docs/cookbook), a [glossary](/docs/guides/glossary) and [flow diagrams](/docs/guides/flows). * Public status page at [status.digitalyser.io](https://status.digitalyser.io), with capability-level probes and an incident feed. **Nothing above changes a response.** These are all documentation of behaviour that was already served: no field appeared, disappeared or changed meaning. A client written against the previous contract keeps working, and now has a schema to validate against. # Status and incidents (/docs/operations/status) **[status.digitalyser.io](https://status.digitalyser.io)** carries the live state, the incident history, and an RSS feed you can subscribe to. ## What the probes actually test [#what-the-probes-actually-test] A status page that pings hosts tells you the edge answered. Ours tests capabilities: | Component | What is verified | | ---------------------- | ---------------------------------------------------------------------------------------- | | MCP endpoint | A refusal still carries its re-authorisation challenge — not just that something replied | | Authentication & OAuth | Discovery documents are served **and** still name an authorization server and `S256` | | API | The public readiness probe | | Applications | Dashboard and site over their public hostnames | | Certificates | Expiry, well before it becomes an incident | Probes run against public URLs, from outside our own network. A probe living inside the infrastructure it watches stays green through an edge outage — which is precisely when you need it to be honest. ## Third-party dependencies [#third-party-dependencies] Model providers, database, CDN. When one of them degrades, you will see it named on the status page with a link to their own status, because "it is not us" is not an answer you can act on. ## Incidents [#incidents] Every incident gets a timeline: what broke, what we know, what changed, and when it closed. If the cause was ours, the page says so. # Support (/docs/operations/support) Integration questions go to **[developers@digitalyser.io](mailto:developers@digitalyser.io)**. One address, read by the people who maintain the API — not a general inbox that forwards. **We answer within two business days.** That is a ceiling, not an average, and it is what we can hold without an on-call rotation. Mail sent on a Friday evening is answered by Tuesday at the latest. ## What to put in the first message [#what-to-put-in-the-first-message] A report that carries these four lines is usually answered once instead of three times: | Include | Where to find it | | ---------------------- | ---------------------------------------------------------------------------------------- | | The request identifier | `instance` in the [Problem Details](/docs/reference/errors) body of the failing response | | The operation | `operationId` from the reference page you called, or the `capabilityId` you invoked | | Time, in UTC | The moment of the call, with its timezone — ours are logged in UTC | | The refusal itself | The HTTP status and the `type` of the problem document | Never send an API key, an access token or a refresh token — not even redacted. We never need one to reproduce a refusal, and an address that has received a credential has to be treated as a leak. ## Where else to go [#where-else-to-go] | Subject | Channel | | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | A vulnerability | `security@digitalyser.io`, under [our disclosure policy](/docs/compliance) — acknowledged within two business days | | An outage in progress | [status.digitalyser.io](https://status.digitalyser.io), which carries the incident and its timeline before any mail we could send | | A quota that is too tight, or a plan change | Your organisation's subscription page in the product — a quota is a commercial boundary, not a bug | | A breaking change you want warned about | The [changelog feed](/changelog.json), and [versioning](/docs/operations/versioning) for what we consider breaking | ## What we do not promise [#what-we-do-not-promise] No 24/7 line, no phone escalation, no service credits. Two business days on a European calendar is the whole commitment — announcing an hour and missing it would be worth less to your planning than a delay we actually hold. # Versioning and deprecation (/docs/operations/versioning) Publishing an interface is a promise about the future. Here is exactly how far ours goes. ## The contract [#the-contract] The REST facade is versioned in its path: `/v1`. Within a version, changes are **additive only**. We may add, without notice: * a new field to a response; * a new optional parameter; * a new capability, a new endpoint, a new value in a non-exhaustive enumeration. Write your client so that these are not breaking changes: ignore fields you do not know, and do not assume a list of values is closed. We will not, within a version: * remove or rename a field or a parameter; * change the type or meaning of an existing field; * change an identifier that was documented as stable; * make an optional parameter required. ## Deprecation [#deprecation] A deprecation is announced in the [changelog](/docs/operations/changelog) and stays available for **twelve months**. Deprecated surfaces keep working for that whole window — they do not degrade, they do not slow down, and they do not start warning in ways that break parsing. There is no silent breakage. If we cannot honour that for a security reason, the change is announced as a security advisory with its own timeline, and it says why. ## Current deprecations [#current-deprecations] **None.** As of 3 October 2026 nothing in the public contract is deprecated, so no removal date is running. A surface that appears here carries three things from the day it is announced: what is being retired, the date the twelve-month window opens, and the date it closes — which is the earliest day it can disappear. | Surface | Announced | Removal not before | Replacement | | ---------- | --------- | ------------------ | ----------- | | *No entry* | — | — | — | ### How you are told [#how-you-are-told] Through the [changelog](/docs/operations/changelog), as a **Deprecated** entry, and through its two feeds: [RSS](/feed.xml) and [JSON Feed](/changelog.json). That is the whole mechanism today. We do **not** send the `Deprecation` and `Sunset` response headers ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745), [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)), so a client cannot learn of a deprecation by inspecting responses. Subscribe to a feed, or watch the changelog. We would rather say this than let you wait for a header that never arrives. Direct notice to the holders of a key — a message to the person who created the credential — is planned, and needs us to know who holds which credential first. Until then, the feeds are the only channel. ## MCP protocol revisions [#mcp-protocol-revisions] The MCP protocol itself changes on a cadence we do not control — the July 2026 revision removed SSE resumption, and that is why we serve Streamable HTTP only. We track revisions, we announce the ones that affect clients, and we do not adopt a revision that would silently change what a connected agent is allowed to do. # Error reference (/docs/reference/errors) Every error carries a `type` pointing at its own section on this page. That URI is stable: it is part of the contract, and safe to branch on. ## How to read a refusal [#how-to-read-a-refusal] Refusals follow [RFC 9457](https://datatracker.ietf.org/doc/html/rfc9457) and arrive as `application/problem+json`: ```json { "type": "https://developers.digitalyser.io/reference/errors#insufficient_permission", "title": "agent_authorization_denied", "status": 403, "reason": "insufficient_permission" } ``` **Branch on `reason`, never on `title` or on the wording of a message.** `reason` is the stable identifier — the one listed below and the one after the `#` in `type`. `title` names the error class, which is coarser: several reasons share one. Note that MCP and REST differ here, and only here. MCP wraps every error in a 200 JSON-RPC envelope, so the reason travels in the body; REST returns the real status code. The catalogue is the same on both. ## 401 — Authentication — the credential itself [#401--authentication--the-credential-itself] The transport did not accept the credential. Re-authenticate; do not treat these as permission problems. ### `credential_expired` [#credential_expired] **The credential expired** · HTTP 401 Access tokens obtained by OAuth consent last one hour. **What to do** — Use your refresh token (`dgr_…`) to obtain a new access token. A conforming MCP client does this on its own. ### `credential_invalid` [#credential_invalid] **The credential was not recognised** · HTTP 401 The token is malformed, was never issued, or is not the kind this surface accepts. A session JWT and a scoped API key both authenticate the transport but open no capability. **What to do** — Send an agent project token (`Authorization: Bearer dga_…`). The `WWW-Authenticate` header on the response points at the resource metadata your client needs to authorise itself. ### `credential_revoked` [#credential_revoked] **The credential was revoked** · HTTP 401 An organisation administrator revoked this token, or the service key it derives from. Revoking a service key revokes every token derived from it, in one gesture. **What to do** — Ask an administrator to issue a new token. Retrying will not help — revocation is final. ### `membership_revoked` [#membership_revoked] **The person behind this token left the organisation** · HTTP 401 An agent token carries the authority of the person who consented. That membership is rechecked on every call, not frozen at issue time. **What to do** — Re-run the consent flow with a current member of the organisation. ### `support_session_unsupported` [#support_session_unsupported] **This project is not reachable from a support session** · HTTP 401 The bearer of this token has no membership on the project, and is currently in a support session on its organisation. A support session never grants agent chat access by itself. **What to do** — Re-run the consent flow with a member of the organisation who is not in a support session. ## 400 — The request — malformed, not forbidden [#400--the-request--malformed-not-forbidden] The call never reached authorisation. Something about the request itself is wrong. ### `capability_not_invokable` [#capability_not_invokable] **That capability is not invoked directly** · HTTP 400 Write capabilities are not called; they are proposed, then applied. Invoking one directly is a malformed request, not a forbidden one — hence 400 and not 403. **What to do** — Invoke the matching `propose` capability, review the returned proposal, then call `applyProposal` with its handle. ### `project_required` [#project_required] **This capability needs a project** · HTTP 400 The capability is project-scoped and the credential carries no project. **What to do** — Use a token derived for the project you mean. One token, one project — for machines exactly as for people. ### `request_invalid` [#request_invalid] **The request body did not validate** · HTTP 400 A field is missing, of the wrong type, or outside its allowed range. **What to do** — Check the parameter schema of the operation in the reference. The detail stays in our logs rather than the response body, deliberately: a public contract does not freeze the wording of a validation message. ### `scope_in_params` [#scope_in_params] **Scope cannot be passed as a parameter** · HTTP 400 An organisation or project identifier was sent in the request body. A capability takes its scope from the credential, never from a parameter — that is the tenant-isolation boundary. **What to do** — Remove the scope field from the parameters and use a token issued for that project. ## 403 — Authorisation — the call was understood and refused [#403--authorisation--the-call-was-understood-and-refused] The request is well-formed and the credential is valid. What differs is what this caller may do. ### `audience_mismatch` [#audience_mismatch] **That capability belongs to another audience** · HTTP 403 A session exposes one audience and one only. A support capability is not reachable from a tenant session, and the reverse holds too. **What to do** — Use the surface that matches what you are doing. This is a boundary, not a setting. ### `capability_not_allowed` [#capability_not_allowed] **This capability is outside the grant** · HTTP 403 The capability exists and the credential is valid, but the consent that produced this token did not include it. The allowlist is at the grain of the capability. **What to do** — Re-run the consent flow and tick this capability. On MCP the server tells your client which scope to request; on REST the identifier in the message is what to look for on the consent screen. ### `insufficient_permission` [#insufficient_permission] **The bearer lacks the permission** · HTTP 403 Distinct from the previous one, and the difference matters: the grant allows this capability, but the person or key behind the token does not hold the permission it requires. **What to do** — Ask an organisation administrator to grant the missing permission. Re-consenting changes nothing. ### `project_out_of_scope` [#project_out_of_scope] **That project is outside the credential** · HTTP 403 The requested project is not the one this token carries. **What to do** — Use the token derived for that project. An agency following several brands derives one token per client, so a workflow wired for one cannot reach another. ### `request_refused` [#request_refused] **The request was refused, without a more specific reason** · HTTP 403 A generic refusal that no more precise reason covered. **Seeing this is itself worth reporting**: every refusal should carry a specific reason, and this one means a case escaped classification. **What to do** — Report it with the response body. It tells us a reason is missing from the catalogue. ### `routing_hint_mismatch` [#routing_hint_mismatch] **The project in the path is not the one in the token** · HTTP 403 `\{projectId}` in the URL is a **routing hint**, kept so multi-project code stays uniform. The authorizer compares it with the credential and refuses when they diverge — the project comes from the token, never from the path. **What to do** — Put the project of your token in the path, or switch tokens. ## 404 — Not found [#404--not-found] The identifier or the route does not exist on this surface. ### `capability_unknown` [#capability_unknown] **No capability by that identifier** · HTTP 404 Usually a typo. The response carries a `suggestions` array with the closest identifiers in the catalogue. **What to do** — Call `GET /v1/capabilities` to list what this credential may use, and copy the identifier from there. ### `route_unknown` [#route_unknown] **No route at that path** · HTTP 404 The path does not exist on this surface, or the HTTP method is wrong for it. **What to do** — Check the path and method against the reference. Note that `/v1` is served under the `/api` prefix: the full URL is `https://app.digitalyser.io/api/v1/…`. ## 409 — State — right call, wrong moment [#409--state--right-call-wrong-moment] Nothing is wrong with the request or your rights. The target is not in a state that allows it. ### `diff_forbidden` [#diff_forbidden] **The proposal targets something it may not change** · HTTP 409 The diff reaches a field or an item outside what this capability is allowed to write. **What to do** — Narrow the proposal to the fields the capability covers. Re-consenting does not widen this — it is a property of the capability, not of the grant. ### `diff_stale` [#diff_stale] **The content changed since the proposal was computed** · HTTP 409 Someone — a person or another agent — modified the target between the proposal and the apply. Applying now would silently discard their change. **What to do** — Recompute the proposal against the current content. This is the most common refusal on the write path, and it is a safety net, not a fault. ### `expired` [#expired] **The proposal expired** · HTTP 409 Proposals are short-lived on purpose: applying a stale diff would overwrite work done since it was computed. **What to do** — Recompute the proposal and review the fresh diff before applying it. ### `invalid_handle` [#invalid_handle] **That continuation handle cannot be used** · HTTP 409 The handle is forged, expired, already used, or belongs to another principal. Possession of a handle is never treated as authentication. **What to do** — Resubmit the request that produces a fresh handle. Do not retry with the same one. ### `not_found` [#not_found] **No proposal by that handle** · HTTP 409 The handle does not name any proposal. Usually a handle from another session, or one already garbage-collected. **What to do** — Invoke the `propose` capability again and apply the handle it returns. ### `not_pending` [#not_pending] **That proposal was already resolved** · HTTP 409 It has already been applied, or explicitly discarded. A proposal is applied once. **What to do** — Nothing to retry. Read the current state before proposing a new change. ### `project_unavailable` [#project_unavailable] **The project exists but cannot be worked on right now** · HTTP 409 Its setup is incomplete, or an operation in progress holds it. This is a state, not a permission — hence 409. **What to do** — Retry later. If it persists, the project needs attention in the dashboard. ## 422 — Content refused [#422--content-refused] The call was well-formed and authorised; what it carried was not accepted. ### `diff_unsafe_content` [#diff_unsafe_content] **The outgoing content was refused** · HTTP 422 The output filter rejected what the proposal contained. The call was well-formed and authorised — hence 422 and not 400 or 403. **What to do** — Adjust the inputs and propose again. The offending detail stays in our logs rather than the response, deliberately: echoing it back would make the refusal a way to read what it blocks. ## 429 — Limits — two mechanisms, one status code [#429--limits--two-mechanisms-one-status-code] A rate limit resets within the minute; a quota resets with the billing period. The reason field tells you which one you hit. ### `quota_exceeded` [#quota_exceeded] **The plan quota is spent** · HTTP 429 A quota counts **work performed** over the billing period — audits run, items produced. It is not the rate limit, even though both answer 429. **What to do** — Wait for the period to reset, or upgrade the plan. Retrying sooner returns the same answer. ### `rate_limited` [#rate_limited] **Too many requests, too fast** · HTTP 429 The rate limit counts **requests per minute**, per credential and per project. Distinct from the quota above: this one resets within the minute. **What to do** — Back off and retry. The limit is shared across MCP and REST — the same credential is counted once, whichever transport it uses. ## 500 — Our side [#500--our-side] These are failures, not refusals. Retry with backoff and check the status page. ### `internal_error` [#internal_error] **Something failed on our side** · HTTP 500 An unhandled failure. The detail stays in our logs. **What to do** — Retry with backoff, and check status.digitalyser.io. If it reproduces, the developer contact on the portal is the way through. ## 503 — Our side [#503--our-side] ### `authorization_unavailable` [#authorization_unavailable] **Authorisation could not be computed** · HTTP 503 A dependency needed to decide was unreachable. **This is our failure, not a refusal** — answering 403 would send you looking for permissions you already have. **What to do** — Retry with backoff. If it persists, status.digitalyser.io carries the incident. # API reference (/docs/reference) The reference below is generated from the capability registry — never written by hand. Each page carries the contract version and the commit it came from. ## Generated, not written [#generated-not-written] Every reference page is generated at build time from the capability registry itself — the same declaration the server reads to decide what exists, who may call it, and what parameters it takes. Nobody edits these pages. A capability that is removed disappears from this reference on the next build, and a build whose generated reference differs from the committed one fails. That is not tidiness. A reference maintained by hand drifts, and a drifted reference is worse than no reference: it is a documented promise the server does not keep. ## What appears, and what never does [#what-appears-and-what-never-does] Only capabilities that are `read`, or the `propose` half of a proposal pair, and only those addressed to tenants. A `write` capability is not listed anywhere, because it is not directly callable — it is reached through the proposal it belongs to, and listing it would invite a client to try. ## The contract itself [#the-contract-itself] The OpenAPI document these pages are generated from is served as-is at [`/openapi.json`](/openapi.json). It is the same file the server publishes — not a copy kept in step by hand — so it carries the version and commit shown at the bottom of every reference page. Point a client generator at it, load it into Postman or Insomnia, or diff it between releases to see exactly what changed: ```bash curl -O https://developers.digitalyser.io/openapi.json ``` Everything the document declares is served, and everything served is declared: response bodies are generated from the capability payloads intersected with the surface allowlist, so a field that appears here is a field the API returns. ### Use it in your tools [#use-it-in-your-tools] Postman, Insomnia, Bruno and Hoppscotch all import OpenAPI 3 directly — paste the URL above, or the downloaded file. The import already carries what you would otherwise configure by hand: | from the document | what your client gets | | ----------------------------- | ---------------------------------------------- | | `servers` | the base URL, `https://app.digitalyser.io/api` | | `securitySchemes.agent-token` | bearer auth, pre-wired on every operation | | `security` at the root | every operation marked as requiring it | Only one thing is yours to add: the token value itself. Put your `dga_…` token in the collection or environment variable your tool created for the bearer scheme, never in a request URL — see [credentials](/docs/guides/credentials) for how to obtain one. Client generators read the same file: `openapi-generator`, `oapi-codegen`, `openapi-typescript` and their equivalents all take the URL directly. **We do not ship a Postman collection**, and that is deliberate. A collection is a *second copy* of the contract — it would have to be regenerated on every API change, and the day someone forgot, it would describe an API that no longer exists. The document above is the one the server publishes; importing it is always current, and it works in every tool rather than one. ## Errors [#errors] Every refusal this API can return has its own entry in the [error reference](/docs/reference/errors), and the `type` of a refusal points straight at it. That URI is stable — it is part of the contract, and safe to branch on. Capabilities restricted to a plan are listed **and** marked as such. Gating happens when a connection is authorised, not when you read a page: you should be able to see what you would get before you buy it. # AI discoverability audit results (/docs/reference/audits/getAiDiscoverabilityAudit) How the project surfaces in AI assistants — whether it is cited, on which prompts, and against which competitors. Measured from neutral accounts, deliberately: an assistant queried from your own logged-in account personalises its answers, and would report a visibility nobody else sees. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Analytics audit results (/docs/reference/audits/getAnalyticsAudit) Results of the project's most recent analytics audit — traffic, acquisition channels, engagement, and the coverage of the measurement itself. Coverage is part of the answer, not a footnote: a channel with no data and a channel that was never instrumented look identical in a chart and mean opposite things. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Cross-module audit overview (/docs/reference/audits/getAuditOverview) Aggregated scores for every audit module — local visibility, online reputation, online presence, content, analytics, AI discoverability — plus a comparison radar against the competitor benchmark. **Read the confidence fields before the numbers.** `competitorAvg` is `null` whenever no retained competitor has been measured on that pillar, and `competitorAvgReason` then says why (`cohort-below-minimum` or `no-competitor-measured-this-pillar`). `competitorPosture` is a verdict on the **cohort** — its coverage and freshness over a 30-day window — never on an individual axis: a `High Confidence` cohort can still carry a `null` average on a pillar nobody measured. Competitors themselves are not scored here. Read them through `GET /competitors`, whose `assetFreshness` measures something else entirely — crawl assets, roughly a day — and not the audit freshness `competitorPosture` is built on. Two different clocks. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Competitors tracked for this project (/docs/reference/audits/getCompetitors) Competitors attached to the project. The `view` parameter selects **which population**, and they are not interchangeable: * `benchmark` (default) — the shortlist arbitrated by SERP recurrence. **The only comparable list**, and the one the overview radar is built on. * `market-map` — those accepted by the latest discovery run, typed. * `inspiration` — the editorial pool. * `pool` — the historical population across all runs: accepted, editorial sources and transient failures. A sorting surface, not an answer to « who are my competitors ». Each entry carries `assetFreshness`, the state of the **crawl** assets used for editorial inspiration (expiry around a day). This has no relation to the **audit** freshness (30-day window) behind `competitorPosture` in the overview: an entry marked `stale` here can perfectly well belong to a `High Confidence` cohort there. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Content audit aggregates (/docs/reference/audits/getContentAudit) Aggregated results of the content audit: coverage, quality signals and technical findings across the **pages discovered on the project's site**. **These are crawled pages, not the editorial pieces of `GET /contents`.** The two are different populations and they do not overlap: this endpoint counts what the crawler found and audited, the other lists what the content engine produced. A project can show sixty-eight here and zero there, and neither number is wrong. `avgScore` is `null` — never `0` — when nothing in a group has been scored. Its denominator is `scoredCount`, not `count`: a group can carry many pages and no score at all. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Local visibility audit results (/docs/reference/audits/getLocalVisibilityAudit) Results of the project's most recent local-visibility audit — the Google Business Profile, its completeness, and local search positioning. A profile the audit *detected* is not a profile the project has *connected*: detection comes from a public lookup, connection from an OAuth grant. Only the second gives write access. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Online presence audit results (/docs/reference/audits/getOnlinePresenceAudit) Results of the project's most recent online-presence audit: social networks, search engines, platforms, and the recommendations derived from them. An absent platform and an unreachable platform are **not** the same thing, and the payload keeps them apart. Treating a fetch failure as an absence would report a problem the project does not have. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Online reputation audit results (/docs/reference/audits/getReputationAudit) Results of the project's most recent reputation audit: reviews, ratings, their distribution over time, and the sources they were collected from. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Apply a proposal — the diff is recomputed and compared before writing (/docs/reference/capabilities/applyProposal) Applies a diff produced by a `propose` capability. **The diff is recomputed and compared against the stored one before anything is written** — this is the only write path, and it refuses rather than overwrites. A proposal is applied once. `diff_stale` means someone — a person or another agent — changed the target between the proposal and the apply, and applying now would silently discard their change. It is the most common refusal here, and it is a safety net, not a fault: recompute and review the fresh diff. Publishing to an external network is never part of this. No capability declares it; it stays a human gesture. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Run a capability — returns a value, a questionnaire, or a proposal (/docs/reference/capabilities/invokeCapability) Runs one capability from the catalogue. **Three shapes of success**, and a client has to handle all three: * `value` — the result, and the interaction is over. * `input_required` — a questionnaire to fill and send back through `requestState`. The handle is single-use. * `proposal` — a computed diff. **Nothing has been written**; `applyProposal` decides. The project is never a parameter. It comes from the credential, and a scope key sent in `params` at any depth is refused with `scope_in_params` rather than ignored — silently dropping it would let a caller believe they had switched project. A capability the catalogue marks `writesOnInvoke` has already done its work when this returns. Ask before calling one, not after. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # List the capabilities this credential may use (/docs/reference/capabilities/listCapabilities) The catalogue, as this credential sees it. **Not a fixed list**: it is computed per call from the plan, the permissions of the bearer, the allowlist of the consent that produced the token, and the project the token carries. Two credentials on the same project can see different catalogues, and the same credential can see a different one tomorrow. Each entry carries its `paramsSchema` — the JSON Schema of what `invoke` accepts. An empty `properties` means the capability takes no parameters, and nothing else. **Read `writesOnInvoke` before calling anything.** It marks the capabilities whose invocation already starts the work and consumes quota — there is no dry run for those. The others declared `propose` only compute a diff: calling them changes nothing until `applyProposal` follows. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Brand voice settings (/docs/reference/editorial/getBrandVoice) The project's brand voice: active style, form of address, technical level, language, and per-channel overrides. `configured` and `enabled` say different things: a voice can be defined and switched off. Reading one for the other would apply a style the project has deliberately disabled. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # A single editorial calendar entry (/docs/reference/editorial/getCalendarItem) One calendar entry: its framing, its production status, and the reason it failed if it did. **This is the polling probe for social production.** A piece moves through `planned` → `in_production` → `validating` → `ready_to_post`, or lands on `rejected`/`failed` with a `failureReason`. Read it until the status is terminal; there is no callback. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # A single content item (/docs/reference/editorial/getContent) One **editorial piece** in full: its topic, its body, the formats derived from it, and where each has been published. Not a crawled page — those live under `GET /audits/content`. A content item and its formats are not the same object, and they do not share a lifecycle. The item carries the substance; each format is a rendering of it for one channel, with its own production status. An item that reads as finished can still hold formats that are not. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Editorial strategy for this project (/docs/reference/editorial/getEditorialStrategy) The project's editorial strategy: context, personas, content clusters, and the state of its generation. The generation state is part of the answer. A project whose strategy has never been generated returns an empty one — that is « not yet produced », not « nothing to say », and the two would look identical without the state. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Editorial calendar entries (/docs/reference/editorial/listCalendarItems) The project's social editorial calendar: reels, carousels and posts, planned or in production, each with its status. **This is the social calendar, not the content list.** The two are separate surfaces: a content item is written matter, a calendar entry is a piece scheduled for a social channel. They are produced by different pipelines and neither contains the other. The status of a piece governs what may be done with it: only `validating` accepts a revision, and publishing to the network is never done here — it stays a human gesture, and no capability declares it. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Content items for this project (/docs/reference/editorial/listContents) The **editorial pieces produced for this project**, paginated — their topic, the formats derived from them, and the production status of each. **These are produced pieces, not the crawled pages of `GET /audits/content`.** The two are different populations and they do not overlap: this endpoint lists what the content engine wrote, the other counts what the crawler found on the site. A project that has audited sixty-eight pages and produced nothing returns an empty list here — that is an accurate answer, not an error. This is the list behind `GET /contents/{contentId}`. It carries enough to choose a piece, not the piece's body. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Editorial topics for this project (/docs/reference/editorial/listTopics) Editorial topics, paginated. **These are the source of the identifiers a content item is derived from** — an integrator that creates content starts here. Archived and rejected topics are hidden by default. `includeArchived` brings them back, and this matters: an archived topic is still derivable. Hidden is not the same as unusable. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}