Cookbook
Four tasks end to end, each one built around the mistake it is easy to make.
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
curl -sH "Authorization: Bearer $DGA_TOKEN" \
https://app.digitalyser.io/api/v1/projects/$PROJECT_ID/audits/overviewOne 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:
{
"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.
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:
# 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…"]}'{
"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
}# 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/applyThe 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 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
Both list operations paginate. They do not paginate the same way, and nothing in their names says so.
// GET /v1/projects/{projectId}/contents
{
"contents": [ /* … */ ],
"pagination": { "page": 1, "pageSize": 20, "totalItems": 137, "totalPages": 7 }
}// 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:
const hasNext = body.hasNextPage
?? (body.pagination ? body.pagination.page < body.pagination.totalPages : false);Survive refusals: what to retry, and what never to
Every refusal is an RFC 9457 problem document. Branch
on reason, never on the prose:
{
"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:
RateLimit-Policy: "credential";q=60;w=60, "project";q=120;w=60
RateLimit: "credential";r=58;t=42, "project";r=115;t=42r is what remains, t the seconds until the window resets. The full list of refusals is in the
error reference.