architecturedomaindevops

Render’s OpenAPI spec is accurate about most things and quietly wrong about one that matters. This cairn records what each task endpoint actually returns, and the diagnostic that will answer the next question of this shape without spending another six days on it.

The four endpoints

Endpoint Returns Cost What it means for us
GET /tasks?label_name=X 37 fields per task, incl. an inline photos stub 1 call per ECO Everything the card renders today. Also tells us whether a task has photos — for free
GET /tasks/{id} 40 fields — the same plus form_response, form_version_id, units 1 call per task The only place Construction Notes exists
GET /tasks/{id}/photos Photo detail incl. image_link (expires after 1 hour) 1 call per task The only place a viewable image exists
GET /task_types/{name} The form schema — display labels for form fields 1 call per task type, cacheable Optional: turns the raw key into the label you see in Render

Read the Cost column first. Three different billing units appear in four rows — per ECO, per task, per task type — and the trap is that they look interchangeable in a design doc. A feature that mixes them without noticing has a cost that scales with the size of the ECO while its author believed it was a constant.

The other three columns are the part the spec gets wrong, and the rest of this cairn is about how we know.

What the list endpoint withholds

Both task endpoints are typed against the same TaskResponse schema. Page_TaskResponse_.data is an array of it; the single-task get returns it directly. Read the spec and you would conclude they return the same object.

They do not. The list returns 37 fields where the schema declares 40. Exactly three are absent — not null, not empty, not sent:

Missing from the list response Spec description
form_response “The Task’s asBuilt form data”
form_version_id The form schema version used for that form data
units “The Task’s unit information”

They form one cluster, and the spec’s own wording ties them together: form_version_id describes the version that produced form_response data “with the Task’s unit information”. The three travel together, including in their absence.

Confirmed by fetching task 302 from both endpoints in the same process and diffing the key sets. The detail response carries forty keys, with form_version_id: 1 and form_response populated.

Warning

This is the one place Render’s spec is actively misleading rather than merely incomplete. Assume there are others, and prefer a measured response to a declared type wherever the check is cheap.

Case study: Construction Notes

The field renders in Render’s web UI as “Construction NOTES” under Task Details. The key in the payload is constructionNotes, camelCase. That UI string is a display label drawn from the form schema; it appears nowhere in the JSON.

Code written from the screen therefore reads a key that does not exist — which is not an error in any language we use. It is an empty string, a nil map entry, an undefined. The code runs, the deploy is green, the panel is blank, and nothing says why.

Worse, the keys are versioned. Every task has a task type, every task type prescribes a form schema, form schemas are versioned, and a completed task’s form_response is paired with the form_version_id that produced it. Two tasks of the same type can legitimately carry different field names.

So: render every field form_response carries, generically. If you need names in advance, row four — GET /projects/{p}/task_types/{task_type_name} — returns TaskTypeResponse.form_schema, which lists every version of the form and needs no completed task. What will not work is reading names out of the spec: FormSchema is additionalProperties: true, so the specification structurally cannot enumerate them. Only a live call can.

Case study: photos

Two shapes, and the difference is the whole story:

Fields Has image URL?
Inline on a task (photos array) — TaskPhotoDetailsBase name, type, image_upload_status No
Dedicated endpointTaskPhotoDetails above plus timestamp, location, image_link, metadata Yes

The list tells you a photo exists — free, inside a call we make anyway — and cannot show it to you. The image is a per-task call, and the link it returns is valid for one hour. Verbatim from the spec: “Each photo’s image link is valid for only 1 hour if you wish to access and download the file photo.”

A stored image_link therefore becomes a broken image later, and the failure is delayed rather than immediate. Persist photo presence and identity; fetch the link at time of use.

Two smaller facts: UploadStatus is exactly pending | completed, and photos come back for tasks Strike did not create — task 302 is a crew task completed by team Redshifted, and its inline array matched Render’s own UI.

What this costs

Both case studies are per-task calls. A four-task ECO showing notes and images is nine requests where it is currently one, and the multiplication is the unit mismatch rather than any single endpoint: row one is billed per ECO, rows two and three per task.

The daily quota is not the constraint — 10,000/day is untroubled. Concurrency is. config.go splits Render’s five concurrent slots by hand, three to the polling worker pool and two to the GraphQL live-fetch path the UI uses, because the two pools are mutually unaware and additive.

Render Is a Shared Request Budget covers that budget properly. The short version for anyone reading the table above: per-task work belongs behind a click, not on page load.

How to document the rest

The table exists because of a technique, and the technique is more durable than the table.

Strike’s poller decodes Render task JSON into a narrow Go struct. That is correct for a poller and is exactly why the question was hard: fields Strike does not model vanish silently at the decode boundary. You cannot find an unmodelled field by inspecting modelled data, and you cannot find it in a spec that types both endpoints identically.

The diagnostic decoded the raw page body a second time into an untyped key map, and logged the shape: which top-level keys the task carries, which keys form_response and task_metadata hold, the form version, whether photo stubs arrive.

Four constraints made it safe to run in production, and all four are worth copying:

  • Field names only, never values. Those fields hold crew-entered customer content, and logs outlive the question being asked.
  • One line per distinct shape. The poll loop runs every few seconds. The dedupe key covers everything the line reports, so no shape is silenced by whichever task arrived first.
  • Absent and empty logged differently. “Arrived empty” and “did not arrive” are different answers.
  • Decoded separately from the real struct, so the observation stays clear of RenderTask, which is marshalled wholesale into change detection and the durable cache.

The single-task difference was then measured rather than inferred, by a probe that fires once per project behind an env flag, with a 3-second timeout and no retries. The inference was strong and would have been correct. It was checked anyway, because the declared schema had already been wrong once about this exact endpoint pair.

Key Takeaway

The technique needs no API credentials of your own — Strike already holds working ones. That makes it the cheapest way to answer any “what does Render actually send?” question.

Running Endpoint Diagnostic Reference

The diagnostic was removed in PR #1823 once it had answered. The only thing you need to bring it back is that revert’s SHA, 4aeb8a2b, which is an ordinary ancestor of origin/main — so reverting the revert works from any checkout, with no special setup:

cd osprey-strike
git checkout -b diagnostics/<your-question> origin/main   # any new name; see below
git revert --no-commit 4aeb8a2b

Name that branch after whatever you are investigating. It cannot be diagnostics/1419-render-payload-discovery, because that branch already exists and checkout -b refuses to overwrite one — it is the pin, a bookmark carrying no unique commits, kept only so the deleted code has a findable name. You are creating a new branch for a new question, not reusing the marker.

The revert restores four things: payload_discovery.go, its test, the body buffering in getTasksPageByLabel (so both decodes see the same bytes), and RENDER_DISCOVERY_PROBE_DETAIL in the osprey-main overlay. The call site on today’s main is unchanged from the merge-base, so it applies cleanly. Drop the configmap hunk for passive key logging without the extra request.

Then grep the API logs:

render task carries fields Strike does not model
render single-task detail carries fields the list endpoint omits
render single-task detail probe did not complete

The first fires per distinct list-payload shape, the second once per project with the flag set, the third tells you why the second is not coming.

Where the existing documentation lives

Render’s own developer docs are mirrored in the sibling repo render-cli under docs/render/ — twenty files covering authentication, pagination, rate limiting, labels, task photos, and the forms model.

As of September 1, 2026, Render has also allowlisted the constructured.com domain for developer.rendernetworks.com. Anyone with a Constructured email address can request a magic link from the docs site. Sessions remain active for 30 days; after that, request a new magic link from the same site.

The OpenAPI spec exists twice. render-cli/openapi/schema.json is valid JSON; osprey-strike/docs/mock/mock_openapi_spec.json has a trailing comma at line 69 and defeats every standard tool. Despite the path, the second is Render’s document rather than ours — info.title reads “Render Networks” — and the two are otherwise identical.

Two coordinates are not guessable from anything else. Auth is OAuth2 client_credentials with no password grant, and the UAT auth domain is https://auth.produs.rendernetworks.com — production US auth serving the UAT app, not auth.uat. Credentials are generated by an Organization Administrator under Account Admin → API Access; Project Admin is not sufficient.

One more undocumented shape while we are here: a task’s labels serialises as a map of group name to values — {"General": ["KCO1-00104-2026"]} — not a flat array, though task creation accepts "labels": [{"name": "..."}]. Correlation by label is exact-match and silent on failure.

The mock is not evidence

Issue Gap
#1684 Mock emits RFC3339 dates; Render documents YYYY-MM-DD. We shipped a broken date parser that passed every local gate
#1696 Mock always labels follow-on tasks, so an unlabelled task is unreproducible locally
#1779 Mock populates neither form_response nor photos; its Photo shape does not match Render’s

Each is the mock being more accommodating than the real API. A mock stricter than production is an annoyance; a looser one is a liability, because it converts “this is broken” into “this is green” at exactly the moment you would most like to know.

Takeaways

  1. Read the Cost column first. Three billing units across four endpoints — per ECO, per task, per task type — and they look interchangeable in a design doc.
  2. The list returns 37 of the schema's 40 fields. form_response, form_version_id and units are not sent. This is the one place the spec actively misleads.
  3. Key names come from the payload, not the screenconstructionNotes, not "Construction NOTES" — and form schemas are versioned per task type, so render form_response generically.
  4. Photo presence is free; the image is a per-task call whose link expires in an hour. Persist identity, fetch at time of use.
  5. To document the next unknown, decode the raw body twice — once into the real struct, once into an untyped key map — and log key names only, once per shape.
  6. git revert --no-commit 4aeb8a2b re-arms the diagnostic. The branch is a pin, not a fork.
  7. Constructured email addresses can access Render's docs by magic link. Render allowlisted constructured.com on September 1, 2026, and docs sessions last 30 days.
  8. The mock is not evidence. Three open issues say so; local green proves nothing about wire format.

Discussion prompts

  • The spec was wrong once about the list/detail pair, which is why the second finding was measured rather than inferred. What else in Strike's vendor integrations is currently believed rather than measured, and which of those would the same two-decode technique settle in an afternoon?
  • The diagnostic was reverted as soon as it answered. Is that the right lifecycle for a tool this cheap to run, or should the shape logging live permanently behind its flag so the next question needs no archaeology at all?
  • Three open issues say the mock is looser than Render. Do we tighten it toward the vendor's documented behaviour, or move wire-format confidence entirely into contract tests against UAT and accept the mock as a convenience fixture?

References

  1. Osprey Strike #1419, the measurement comment — the endpoint/cost table this cairn documents.
  2. osprey-strike/docs/api/render-api-schema-notes.md — the durable schema notes, with every claim marked observed, documented, or inferred.
  3. osprey-strike/packages/api/internal/process/render/polling/config.goRenderBurstLimit, DefaultWorkerPoolSize and ResolverFetchBudget.
  4. render-cli/docs/render/ and render-cli/openapi/schema.json — Render's mirrored documentation and the parseable copy of the spec.
  5. PRs #1778, #1787, #1791 and the revert #1823 — the diagnostic's lifecycle, pinned on diagnostics/1419-render-payload-discovery.