Digitalyser.io for developers

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/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:

{
  "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 seewhat it meanswhat to do
score: nullthe score was not producedread scoreAvailabilityState; show "not measured", never 0
status: "completed" with score: nullthe run finished, the score did notthe two are independent
competitorAvg: nullno retained competitor measured this pillarread competitorAvgReason
competitorPosturea verdict on the cohort's coverage and freshnessnever 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/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:

reasonwhen
diff_forbiddenthe proposal targets something it may not change
expiredyou waited past expiresAt — propose again
not_pendingthat proposal was already applied; a proposal is applied once
not_foundno proposal by that handle
invalid_handlethe 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"
}
statusreasonretry?
429rate_limitedyes, after Retry-After seconds — never sooner, never immediately
429quota_exceededno. A plan ceiling, not a burst; retrying changes nothing
403capability_not_allowedno. Outside the consent grant — the person must re-consent
403insufficient_permissionno. The bearer lacks the permission; an administrator must grant it
403routing_hint_mismatchno. The {projectId} in your path is not the token's project
401credential_invalidno, not as-is. Follow WWW-Authenticate to re-authorise
409diff_stalenot as-is — recompute the proposal first
409expired, not_pendingno. 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=42

r is what remains, t the seconds until the window resets. The full list of refusals is in the error reference.

Was this page helpful?

On this page