What Render Actually Sends
Four endpoints, what each one really returns, and how to find out the next thing the spec won't tell you · ~11 min read ~– min read · Suggested by Laura engineering
Render's OpenAPI spec is accurate about most things and quietly wrong about one that matters: the task list and the single-task get are typed identically and do not return the same fields. This is what each endpoint actually sends, and the diagnostic that will answer the next question of this shape without another six days.
The four endpoints
Four endpoints, and the Cost column decides architecture. The task list is billed per ECO; the single-task get and the photos endpoint are billed per task; the task-type endpoint is billed per task type and is cacheable. Three billing units in four rows, and they look interchangeable in a design doc.
What the list endpoint withholds
The list returns 37 fields where the schema declares 40. Both endpoints are typed against the same TaskResponse, so the spec gives no hint they differ, but form_response, form_version_id and units are not null or empty — they are not sent. Confirmed by fetching task 302 from both endpoints and diffing key sets. This is the one place Render’s spec is actively misleading, and it is worth assuming there are others.
Case study: Construction Notes
The key is constructionNotes; the UI displays “Construction NOTES”. That string is a display label from the form schema and appears nowhere in the JSON, so code written from the screen returns nothing with no error. Form schemas are versioned per task type, so render every key form_response carries rather than looking one up by name; GET /task_types/{name} is the documented route to labels, and the spec cannot list them because FormSchema is additionalProperties: true.
Case study: photos
The inline photos array carries no URL. Presence, type and upload status arrive free on the list; the viewable image_link requires the dedicated endpoint and expires after one hour. Persist presence and identity, fetch the link at time of use.
What this costs
Both case studies are per-task calls, so a four-task ECO showing notes and images is nine requests where it is currently one. The UI path holds two of Render’s five concurrent slots. Render Is a Shared Request Budget covers the budget properly; the short version is that per-task work belongs behind a click.
How to document the rest
Strike’s poller decodes task JSON into a narrow struct, so unmodelled fields vanish at the decode boundary and cannot be found by inspecting modelled data. The technique that works: decode the raw body a second time into an untyped key map and log the key names, never values, once per distinct payload shape. It needs no credentials of your own, because Strike already holds working ones — which is what makes it the cheapest way to answer any “what does Render actually send” question.
Running Endpoint Diagnostic Reference
Bringing the diagnostic back needs one SHA, not a special branch. git revert --no-commit 4aeb8a2b — the revert from PR #1823, an ordinary ancestor of origin/main — restores the discovery pass, its tests, the body buffering in getTasksPageByLabel, and the RENDER_DISCOVERY_PROBE_DETAIL flag. Do it on a new branch named for your own question; diagnostics/1419-render-payload-discovery is a pin marking where the deleted code lives, not a branch to reuse. Then grep the API logs for the three messages it emits.
Where the existing documentation lives
Render’s docs are mirrored in the sibling repo render-cli under docs/render/, and the parseable OpenAPI copy is render-cli/openapi/schema.json — Strike’s copy at docs/mock/mock_openapi_spec.json is Render’s own document despite the path, and has a trailing comma at line 69 that defeats every JSON tool. Two coordinates are not guessable: auth is at auth.produs.rendernetworks.com, and credentials require an Organization Administrator.
As of September 1, 2026, Render has allowlisted constructured.com for developer.rendernetworks.com. Anyone with a Constructured email address can request a magic link from the docs site; sessions stay active for 30 days, then the same magic-link flow can be used again.
The mock is not evidence
Three filed issues, one theme. The mock emits RFC3339 dates where Render documents YYYY-MM-DD (#1684), always labels follow-on tasks (#1696), and populates neither form_response nor photos (#1779). Each is the mock being more accommodating than the real API, which converts “this is broken” into “this is green” at the worst possible moment.
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.
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 endpoint — TaskPhotoDetails |
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.
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
- 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.
- The list returns 37 of the schema's 40 fields.
form_response,form_version_idandunitsare not sent. This is the one place the spec actively misleads. - Key names come from the payload, not the screen —
constructionNotes, not "Construction NOTES" — and form schemas are versioned per task type, so renderform_responsegenerically. - Photo presence is free; the image is a per-task call whose link expires in an hour. Persist identity, fetch at time of use.
- 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.
git revert --no-commit 4aeb8a2bre-arms the diagnostic. The branch is a pin, not a fork.- Constructured email addresses can access Render's docs by magic link. Render allowlisted
constructured.comon September 1, 2026, and docs sessions last 30 days. - 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
- Osprey Strike #1419, the measurement comment — the endpoint/cost table this cairn documents.
osprey-strike/docs/api/render-api-schema-notes.md— the durable schema notes, with every claim marked observed, documented, or inferred.osprey-strike/packages/api/internal/process/render/polling/config.go—RenderBurstLimit,DefaultWorkerPoolSizeandResolverFetchBudget.render-cli/docs/render/andrender-cli/openapi/schema.json— Render's mirrored documentation and the parseable copy of the spec.- PRs #1778, #1787, #1791 and the revert #1823 — the diagnostic's lifecycle, pinned on
diagnostics/1419-render-payload-discovery.
Generated by Cairns · Agent-powered with Claude