Findings — write: creating and updating
How the API behaves in this part of a session. Each rule is the entry's own Teaches, copied whole.
011_create_project
A script user can create a Project with nothing but {"name": ...}, at 201, but the response echoes only 6 attributes, so read the project back if you need anything else.
{"name": "sandbox_show"}withContent-Type: application/jsonis a complete create: 201, no project template, nocode, no dates.On the probed site,
GET /schema/Project/fieldsreturns 42 fields and flags exactly one,name,mandatory: true. That count is site configuration, and a schema flag is not the create contract (probe 012), so it does not establish thatnameis the server's only requirement. This probe never posted a body withoutname; that POST, behind--write, would settle it.Trap. The 201 body is not an entity read. It echoes 6 attributes (
cached_display_name,created_at,landing_page_url,name,tracking_settings,updated_at); everything else,idapart, needs a GET on the new project.nameis flagged both mandatory and unique in the schema, so creating is not idempotent. Probe first (GET /entity/projects?fields=name) and reuse the hit, as this probe does. Custom fields silently become<name>_1on a duplicate (probe 019); whether a Project name collides or duplicates is<unverified>.A fresh project has no
sg_status, so a picker must not filter on it (probe 018).
corpus/findings/011_create_project.md
012_create_version
The schema's mandatory flags are not the create contract: on every project-scoped type measured, project is required and the identity field is optional, server-generated and not unique.
Trap.
/schema/Version/fieldsmarks onlycodemandatory, yet a body withoutproject400s.An entity link is a hash:
"entity": {"type": "Shot", "id": N}creates,"entity": <id>400s.projecttakes the identical shape, and reads return both underrelationships(probe 004).The 201
relationshipsblock lists slots that were never set (cuts,playlists,tags), so its keys are not a record of your input.entityappears only when it was written, the one usable confirmation.Both 400s recorded as
null: the probe capturederrors[0].detailand these errors have none. The status codes are verified; the message text is<unverified>.
The create contract, every type measured since. Version is not a special case. One row per card,
entity_types/<Type>:
| type | identity field | flagged mandatory |
actually required | omitted identity becomes | unique |
|---|---|---|---|---|---|
| Version | code |
yes | no | New Version <id> |
no |
| Shot | code |
yes | no | New Shot <id> |
no |
| Asset | code |
yes | no | New Asset <id> |
no |
| Sequence | code |
yes | no | New Sequence <id> |
no |
| Task | content |
yes | no | New Task <id> |
unmeasured |
| Playlist | code |
yes | no | New Playlist <id> |
no |
| PublishedFile | code |
yes | no | New Published File <id> |
no |
| Note | subject |
yes | no | nothing; the row has no title | no |
| TimeLog | none | only id |
n/a | n/a; description reads New Time Log |
no |
| Delivery | title |
yes | no | New Delivery <id> |
no |
For a project-scoped type, project is the requirement and is flagged mandatory: false; the identity
field is flagged mandatory: true and is optional, generated by the server when omitted. {} and the
identity field alone both answer 400 API create() missing 'project' attribute: with the body echoed.
Nothing is unique on any of them, so keying on code is wrong on every type measured: match on id, and
treat a code lookup as a query that can return more than one row.
Omitting the key and sending an empty one are different. {"code": ""} on a create is 400 code 104
Create failed for [Shot]: Cannot set identifier field to empty. (Shot) (entity_types/Shot), and
Sequence answers the same message for "" and for null (entity_types/Sequence).
Site-wide types are the boundary, not the rule. Project has no project field at all, and its identity
name is the one field flagged both mandatory and unique, so a create there is not idempotent
(entity_types/Project).
editable is the same shape of flag and the same shape of wrong. created_at and updated_at are
flagged editable: false on Note, Reply, Task and Version, and a create body sets both (probe 070).
Neither flag describes the create path; send the body and read the 400.
corpus/findings/012_create_version.md
024_read_after_write
Every write ignores ?fields. A create returns what you sent plus the server defaults, an update returns the whole record, and neither resolves a dotted path, so re-read for those and after an upload.
There is no conditional write.
If-Match,If-Unmodified-SinceandIf-None-Matchare ignored and the update applies at 200, though aGETreturns a weakETag.Echoing
updated_atback is refused withAPI update() Task.updated_at is editable on create only.So a read-then-write guard narrows the race window and never closes it, and exactness needs serialisation outside the API (recipes/005).
| operation | the response returns | it omits | how to get the rest |
|---|---|---|---|
POST /entity/<type> |
the fields of the request body, the server-set ones (created_at, updated_at, cached_display_name), and every relationship as {id, name, type} |
every field left at its default, and every dotted path | GET /entity/<type>/{id}?fields=... |
PUT /entity/<type>/{id} |
the whole record, changed fields and untouched ones alike | dotted paths only | the same follow-up GET |
PATCH /entity/<type>/{id} |
nothing: 404 with a null detail |
everything | use PUT |
batch create row |
the same subset a single create returns | as a single create | one GET per created id |
batch update row |
the whole record, wrapped with links and status |
dotted paths only | the same follow-up GET |
batch delete row |
did_delete, id, uuid |
any field value | nothing to re-read |
_upload complete_upload |
201 with a one-byte body | the stored path and the final URL | poll the field until it stops matching /images/status/transient/ |
DELETE /entity/<type>/{id} |
204, empty body | everything | the id answers 404 from then on |
Trap.
?fieldson a write is accepted and ignored, plain names and dotted paths alike, with no error, the same quiet drop a bogus?fieldsname gets on a read (probe 004).The reported create-versus-update asymmetry is real but inverted: the create response is the thin one. Neither verb resolves
project.Project.nameorentity.Shot.code, and both return the link's ownnameunderrelationships, so a second call is needed only for a linked entity's other fields.On the probed site a Version create answers 9 attributes and 15 relationships and an update answers 52 and 20. The counts are site-specific; the ratio is the rule. A create body of 3 extra fields answered 12 attributes, so the create response is what you sent plus the server defaults, not a fixed list.
A batch endpoint does exist, undocumented:
POST /entity/_batch, and it takes plainapplication/json, not the vendor Content-Type_searchrequires (probe 004).{"requests": [{"request_type": "create", "entity": "Version", "data": {...}}, {"request_type": "update", "entity": "Version", "record_id": N, "data": {...}}, {"request_type": "delete", "entity": "Version", "record_id": N}]}entityis the singular schema name, not the URL slug; the id key isrecord_id, andentity_idis read as 0 and 404s. One failing row rolls the whole batch back: a good create paired with an update of id 999999999 left 0 rows behind. The response isdata, one row per request, in order.Transcoding is the only write whose result is not readable at all on return (probe 013). On the probed site a 16x16 PNG thumbnail stayed at
/images/status/transient/thumbnail_pending.pngpast t+21s and resolved by t+42s, so poll on the path prefix and never on elapsed time.Unsettled. One team reports a newly created Version's linked-entity field reading back empty at the moment of its own creation event, and built a diagnostic to fail loudly on it.
This repo has no event listener, so the claim is untested here and a negative result would prove nothing. Settling it needs a listener reading the entity from inside the event callback and comparing against a read a second later.
corpus/findings/024_read_after_write.md
045_webhooks
The hook contract validates the url and the entity type, and silently accepts a field name, a project id and an entity-type count it will never honour. test_connection answers 204 for any uuid. [partial]
not measured: No entity event reached a hook on the probed site, so the delivery payload, the X-SG-SIGNATURE and x-sg-event-batch-* headers, and batch_deliveries, are all unrecorded.
| sent | result |
|---|---|
a field the type does not have, in update |
201, and the hook can never fire |
| two entity types in one hook | 201 |
| a project id that is not there | 201 |
| an action name the API does not have | 400 entity_types is not valid |
| an entity type the site does not have | 400 entity_types is not valid |
The url validator resolves the host and ignores the scheme. Every unroutable target is refused with one message naming reserved and internal addresses,
https://<name>.example.comincluded, whileftp://example.com/...answers 201. Passing validation means the host resolved, not that the hook can be delivered to.test_connectionanswers 204 for a uuid that is not a hook. It confirms nothing: not that the hook exists, not that the endpoint is reachable, not that anything was sent. Do not use it as a health check.POST /webhook/hooksrefuses the vendor array content type that_searchrequires, at 415 namingapplication/json. The webhook family takes plain JSON only (004_array_vs_hash).The two 404 shapes are the parser, not the lookup: a well-formed uuid that names nothing answers code 104 with
detailnaming it, and a segment that is not a uuid answers code 103 withdetailnull.A delivery record is written whether or not anything answers. A hook pointed at a dead host records
status: "delivered"withresponse_code: 0and an emptybody.deliveredmeans dispatched, not received. Never read that status as confirmation a consumer got the payload; readresponse_code, which is 0 when nothing replied.A hook's own status change is delivered to the hook. Setting
statustodisabledand back writes one delivery record each. Its event type is not in the guide's list of 39 custom events:{"data": {"id": "0", "event_type": "Webhook_Status_Change", "event_log_entry_id": 0, "webhook_status": "disabled", "previous_webhook_status": "active", "meta": {"type": "webhook_status_change", "source": "client", "old_value": "active", "new_value": "disabled"}}, "timestamp": "2026-09-04T18:47:18Z"}idis"0"andevent_log_entry_idis0: this is generated by the webhook service itself and has no row in the event log behind it. A consumer must tolerate it, because no subscription asks for it and every toggled hook is sent one.On the probed site, entity events reach no hook, and this is not a REST problem. A hook created in the web interface and one created over REST behave identically:
active, correctly subscribed, and no delivery record for any entity change or fortest_connection, whileWebhook_Status_Changeon the same hook in the same minute records normally.Two public endpoints were tried, a tunnel proven reachable in-process and webhook.site. So the delivery recorder runs and the entity-event feed into it does not. Diagnose a silent hook by toggling its status: a record proves the pipeline is alive and isolates the fault to the event feed.
Because no entity event was delivered,
X-SG-SIGNATURE, the entity payload, thex-sg-event-batch-*headers,batch_deliveries, andPUT /webhook/deliveries/<record_uuid>andredeliverare all unprobed.Everything here uses
entity_types. The second subscription mode the 400 names,event_type, the fourth lifecycle actionrevive, and the entity families the guide excludes are all measured in050_webhook_subscriptions.
corpus/findings/045_webhooks.md
050_webhook_subscriptions
entity_types and event_type are mutually exclusive and one 400 covers giving neither and giving both. revive is a fourth action, and every entity the guide calls excluded is accepted at 201.
The two modes are exclusive, and one message covers both ways of getting it wrong.
entity_types either entity types or event type is requiredis returned for a body with neither and for a body with both. Read it as "exactly one of these", not as "you are missing one".Every entity the guide calls excluded is accepted at 201. A hook on
ApiUser, onEventLogEntry, or on a connection entity such asAssetShotConnectionis created, isactive, and reads back intact. The documentation says it will never fire. Nothing in the API says so, so the create is not the place you will find out.reviveis a fourth lifecycle action alongsidecreate,updateanddelete, and it is the counterpart to the logical delete in040_field_revive.retireis not an action: the API spells the same operationdelete.A bogus
event_typedoes not enumerate the legal ones.event_type is not validnames nothing, unlike the filter operators, which answer a bogus relation with the full list (017_filter_operators). The 39 custom events are readable from the guide and from nowhere in the API.Type errors on
event_typeare specific where the value error is not: a list answersmust be a stringand an empty string answersmust be filled.batch_deliveriesandvalidate_ssl_certround-trip on create and are readable back.
corpus/findings/050_webhook_subscriptions.md
058_local_storage_roots
One create fills every local_path_* the storage row defines, whichever platform's root the path was under. The server picks the deepest matching root, and no conditional-write header is honoured.
One create fills every platform the row defines. A Mac artist publishing under
mac_pathgives a Linux farm a workinglocal_path_linuxin the same write, and the join runs the same way for a path given under the Linux or Windows root: the server strips whichever root matched and re-joins the remainder onto all three.local_path_windowscomes back with backslashes and the drive letter exactly as the row spells them, even though a backslash in the request is refused (recipes/004_register_published_file).A platform reading null is a root the row leaves unset (probe 021), never a property of the write.
A Windows root is matched with forward slashes.
Z:/zzprobe_058_a/seq/plate.v001.exrresolved againstwindows_pathZ:\zzprobe_058_a, so a client normalises separators before sending and still reaches a drive-letter root.The deepest matching root wins, not the oldest row. With
/zzprobe_058_nand/zzprobe_058_n/subboth defined, a path undersubresolved to thesubrow in both creation orders, so id order does not decide it. Two rows on the identical root resolved to the higher id.A client that means one specific storage sends
{"relative_path", "local_storage"}, which names it outright, rather than{"local_path"}.Nothing in the response signals the choice beyond the id.
path.local_storageandpath_cache_storagehold the same row and there is no confidence, no candidate list and no warning, so a client checks by comparing that id against the storage it intended (recipes/004_register_published_filestep 5).There is no conditional write. All six headers were accepted and ignored, each at 200 with the write applied. The
GETdoes return a weakEtag, so it looks like a precondition is available and no request built on it is honoured. The read-then-write race onversion_numbercannot be closed at the API; it stays a client convention.
corpus/findings/058_local_storage_roots.md
069_client_note
client_note cannot be set over REST: true on create is 400 and any PUT is 400 editable on create only. sg_note_type: "Client" is the one marker a caller can write.
Two refusals, one field. The create path answers Client Notes can not be created through the API, and the update path answers Note.client_note is editable on create only. Read together they
close every route: the only call allowed to set the flag refuses true, and false is what an omitted
key already stores. The schema's editable: false is right here, unlike created_at (probe 070).
sudo_as_login changes nothing; the refusal is on the API, not on the identity.
sg_note_type is what a REST caller can write. Both fields are stock (visible.editable false,
probe 056). sg_note_type is an ordinary list: a value outside valid_values is 400 with the
vocabulary in the error, a PUT is 200, and is filters count it. On the probed site the vocabulary
is Internal and Client; read it from the schema, never assume it. Setting it to Client leaves
client_note false, so a client written this way is invisible to a filter on client_note.
Filter both when listing client-facing Notes. A Note the web application flagged and a Note the
API typed are two disjoint sets: client_note is true finds the first, sg_note_type is "Client"
the second. On the probed sandbox, 0 and 108 rows.
corpus/findings/069_client_note.md
070_authored_timestamps
A create body sets created_at and updated_at and they read back exactly, on Note, Task and Version, though the schema flags both editable false; every PUT on either 400s. [partial]
not measured: Whether an EventLogEntry create can date itself is untried: that row cannot be deleted afterwards (probe 025), so the probe did not spend one.
editable: false does not describe the create path. Both timestamps are flagged editable: false
on all four types and both are accepted in a create body. This is the same inversion probe 012 found on
mandatory, where the one field flagged mandatory on a Note is optional and project, which is not
flagged, is required. The server's own error says which half of the flag is real: is editable on create only, not is not editable. Read editable: false as "not editable by a PUT" and test the create.
| verb | created_at |
updated_at |
|---|---|---|
POST |
stored as sent | stored as sent, on the types that have it |
PUT |
400 is editable on create only |
400 is editable on create only |
An authored date is the real one. It is what the row reads back, what created_at filters and
sorts on, and what less_than selects: nothing keeps a separate wall-clock insert time. An import
writes history that queries correctly, and a bug writes rows that a "created this week" feed can never
see.
null is accepted and leaves the row undated. {"created_at": null} answers 201 and the field
reads back None, so every created_at filter and every sort on it drops the row. Omit the key rather
than sending null on a create built by dropping empty values.
updated_atis not onReplyat all: the create 400s withAPI create() Reply.updated_at doesn't exist.created_atis there and takes a value like the rest.The value shapes are the
date_timewrite shapes exactly (field_types/date_time):ISO 8601with or without an offset, a date-only string meaning midnight UTC, an offset normalised to UTC, and"YYYY-MM-DD HH:MM:SS UTC"refused, which is the spelling the create's own 201 echo uses.The 201 echo and the re-read disagree about the format. Three of the four types echo
2019-03-04 05:06:07 UTC, Task echoesISO 8601, and aGETon any of them returnsISO 8601. Parse the re-read, not the echo.sudo_as_loginchanges nothing here: a person's create dates itself exactly as the script's does.Nothing measured here reaches the event log. A create still writes an
EventLogEntrydated now, and that entry cannot be deleted (probe 025), so a back-dated import leaves a forward-dated audit trail.
corpus/findings/070_authored_timestamps.md