SG Ground Truth

Findings — protocol: headers, and what a status code is worth

How the API behaves in this part of a session. Each rule is the entry's own Teaches, copied whole.

API

004_array_vs_hash

api3_array/api3_hash are a POST _search request Content-Type, not a GET Accept header: as Accept they 406, and entity fields are returned under relationships either way.

  • ?fields can change the value of a field you did ask for, not only drop ones you did not. ?fields=display_type,url on an Icon returns url as ""; adding image_data to the same request returns the real data:image/png;base64 URI, and omitting ?fields returns it too. Narrowing a projection is not free, and the wrong answer is a plausible one (recipes/010).

Trap. Where a bogus name appears decides whether you hear about it:

bogus name in result
?fields 200, the field absent
filter[] 400 API read() Version.sg_not_a_field doesn't exist.
a filter operator 400 (probe 017)

Only ?fields fails quietly, and a typo there reads as "no data" rather than "wrong field".

  • Representation is not negotiable: entity and multi-entity fields are returned under relationships as {data, links} under the default, under api3_array and under api3_hash, the two 200 _search rows matching byte for byte. As an Accept header both vendor types 406 with an empty body. Neither one is a rendering switch.

  • application/vnd+shotgun.api3_array+json and ...api3_hash+json belong on POST /entity/<type>/_search (and _summarize), which rejects application/json with 415 (probe 020). What the vendor type selects is the filters syntax of the request body, not the response:

Content-Type filters
api3_array [[field, op, value]]
api3_hash {"logical_operator": "and", "conditions": [[field, op, value]]}

The conditions inside the hash form stay triples. An object of path/relation/values is 400 Missing logical operator, and a bare list under api3_hash is 400 Query is not an Hash. The per-type filters of POST /entity/_text_search split the same way, one key at a time (probe 063).

corpus/findings/004_array_vs_hash.md

028_loud_and_silent

A 400 is trustworthy and usually names the legal set, but a 200 proves nothing: an unknown field, sort key or query param is a no-op, and a batch can return an id for a row it never made.

Every case below is measured elsewhere in the corpus; this entry is the map. The rows marked verified were re-run read-only on the date of this probe and none had changed.

Loud, and usually self-documenting. The rejection names the legal set, which is what made the field-type matrix cheap to build.

sent answer recorded
an operator no data type has 400 naming every legal relation for that data type. All 21 reachable types reject; 16 enumerate, the five unfilterable ones answer cannot be used in a filter (verified) probe 017, field_types/*
a _summarize type that is not one of the fourteen 400 Request Parameters invalid., source.summary_fields indexed by position, naming all fourteen (verified) probe 020
an entity type name that does not exist, in a path or in /schema 404 Entity type '<x>' does not exist., quoting the name back (verified) entity_types/Project, probe 023
a field name that does not exist, in a filter 400 API read() <Type>.<field> doesn't exist. (verified) probe 026
a list value outside valid_values, on a write 400 naming the legal values field_types/list
a wrong Python type, on a write or as a filter value 400 naming the accepted Ruby classes (verified as a filter value) field_types/number, float, percent
a malformed POST /entity/_batch body 400 per missing key, and request_type must be one of: create, update, delete probe 024
sort syntax: empty, a space, a leading + 400 sort must be filled or sort list is not valid (verified) probe 026

Silent. HTTP 200, and the part of the request the server did not understand is gone.

sent answer recorded
a name in ?fields that is not a field, on a read 200, the key absent from attributes (verified) probe 004, probe 023
?fields on a create or an update ignored entirely, both verbs, plain names and dotted paths alike probe 024
?fields=display_type,url on an Icon 200 with url reading "", which is indistinguishable from no image; adding image_data to the same request returns the real data URI, and omitting ?fields also returns it recipes/010
a list filter value outside valid_values 0 rows, no error; inside in the junk member is evaluated and matches nothing, so the rest of the list still returns (verified) field_types/list
sort on an unsortable or unknown field 200 in default order, identical to no sort, ascending and descending alike (verified) probe 026, field_types/summary, field_types/url
["id", "in", [...]] 200, id ascending; the order of the list is discarded (verified) probe 026
any filter on PageSetting.settings_json 200 and the full unfiltered set, while another field on the same type filters (verified) probe 023
any filter on EventLogEntry.audit_trail the same (verified) field_types/jsonb
["read_by_current_user", "in", [...]] on a Note 200 and the caller's unread rows, whatever the list holds; not_in and an is value outside read/unread answer the same. is "read" and is_not do filter probe 068
GET /schema/<Type>/fields/<field> for a field the census omits 200 with data: null, while a name that is nothing at all 404s Field '<Type>.<name>' does not exist. probe 068
filter[] query params on POST _search ignored entirely: a body filter wins and a bogus param name still returns 200, while the same param filters correctly on GET /entity/<type> probe 030
a batch create missing a required attribute 200 with an id for a row that does not exist: GET 404s, _search returns 0, DELETE answers 204. The single-create path 400s on the same body recipes/002_batch

Silent and destructive. Seven writes return success and either do nothing or destroy data. Not re-run here: they are recorded, and re-proving them costs rows.

written answer recorded
cached_display_name 200, the write discarded; the field re-reads as code field_types/text, entity_types/Sequence
Note.read_by_current_user as an ApiUser 200, the echo and the re-read both unread; the same write under sudo_as_login stores probe 068
Task.splits, any well-formed payload 200, null stored field_types/serializable
a multi_entity update mode spelled in the query string 200, the whole list replaced instead of appended field_types/multi_entity
an already-linked Shot added to a second Sequence.shots 200, the first Sequence's shots is now [] entity_types/Sequence
two summary_fields entries over one field 200, the second overwrites the first, last entry wins probe 020
PUT a Note with {"replies": []} 200, the Reply rows deleted outright entity_types/Note

The rule. Trust a 400: the request layer validates operator, summary type, entity name, field name and value class, and says what it wanted. Trust nothing about the parts of a request that select or shape data, because ?fields, sort, a filter value and an update mode are all dropped at 200 when the server does not recognise them, and a write is confirmed by re-reading the row, never by its status code.

corpus/findings/028_loud_and_silent.md

051_api_version

/api/v1 and /api/v1.1 are the same API. Across 20 read-only calls the only difference is api_version in the root document and the prefix each echoes in its own links. Any other segment is 404.

  • The two prefixes are one API. One token authenticates both, every status matches, and every body matches once the prefix each echoes in its own links is normalised. Everything measured in this corpus was measured on /api/v1 and transfers to /api/v1.1 unchanged.

  • links echo the prefix you called. A client that starts on /api/v1.1 stays there through links.next; there is no silent downgrade to follow, and no rewriting to guard against (006_pagination).

  • api_version in the root document reports the prefix that served the request, 1.0 or 1.1. It is not a statement about the site, so it cannot be used to discover which versions a deployment offers.

  • The version is not discoverable from the API. Both specs advertise only /api/v1.1, yet /api/v1 serves the same 62 operations, and there is no listing of valid prefixes. /api/v1.2 and /api/v2 answer 404 code 103 with detail null, the same shape as any unrouted path (045_webhooks), so a probe cannot tell an unreleased version from a wrong URL.

  • Comparing two versions needs two artefacts removed first. Every error body has a per-request errors[].id that changes on every call, and /spec.json contains the literal /api/v1.1 under servers whichever prefix served it. Left in, both read as version differences and neither is one.

corpus/findings/051_api_version.md

062_cors

Every path under /api/v1 answers the preflight and echoes any Origin, credentials true. /internal_api and the web paths send no CORS header, so a page on another origin proxies those. [partial]

not measured: what a live session cookie authenticates on /api/v1, since allow-credentials invites one, and the presigned upload host, which is not this site. A live session needs a person at a browser

  • /api/v1 reflects whatever Origin reaches it. There is no allowlist to be on and nothing to register: null, an http://localhost origin and the string banana all come back in access-control-allow-origin, each with access-control-allow-credentials: true, so a browser permits a credentialed cross-origin call from any page to any Flow PT site.

  • Vary: Origin, Access-Control-Request-Method is on every answer, so a shared cache does not hand one origin the reply meant for another.

  • The preflight is answered in front of the API rather than by the route. Any path under /api/v1 answers 200 with the echo, including one no route serves; a path outside /api/v1 answers 200 with nothing. It takes no token, and the answer has an empty body.

  • What is allowed is fixed, except the header list, which is an echo of a closed set:

    asked for answered
    Access-Control-Request-Method one of GET POST PUT PATCH DELETE access-control-allow-methods: GET, POST, PUT, PATCH, DELETE
    Access-Control-Request-Method: TRACE or BREW 200 with every CORS header dropped, which fails the preflight
    Access-Control-Request-Headers drawn from authorization, content-type, accept, accept-language, content-language, range, origin the same string echoed back
    one name outside that set, even beside a legal one 200 with every CORS header dropped

    A page may send Authorization and the application/vnd+shotgun.api3_hash+json content type (probe 004), and nothing else: cache-control, if-none-match and x-requested-with fail the preflight and the request is never made. The content type is not a safelisted value, so every call is preflighted, and access-control-max-age: 3600 is what keeps that to one extra round trip per hour.

  • No access-control-expose-headers, so script reads only the six safelisted response headers (cache-control, content-language, content-type, expires, last-modified, pragma).

  • etag and x-request-id are on the response and unreadable from a page, and if-none-match is refused at the preflight, so a browser client has no conditional request and no request id to quote in a support ticket.

  • What a page on another origin can call:

    from a page on another origin
    everything under /api/v1 direct. Mint a token, read, filter, write. The _upload flow then leaves the site for a presigned host, whose own answer is not measured here
    /internal_api/*: the App Session Launcher and the session through a proxy. The browser discards an answer with no access-control-allow-origin, at 200, 401 and 404 alike
    /images/..., /dist/... as an <img> or a <link>, which need no CORS. Not through fetch, and pixels read back off a canvas are tainted

    So a page signs a person in by having its own server make the two launcher calls of probe 052, opening the returned url in a tab for the person to approve, and then minting the bearer from the page, since POST /auth/access_token is one of the calls it may make. The alternative, a page holding client_credentials, ships the script key to every visitor.

corpus/findings/062_cors.md

Every entry on this site is the output of a probe in probes/. The corpus is generated by running those probes against a live Flow Production Tracking site, not written from memory.

Not affiliated with or endorsed by Autodesk. Flow Production Tracking is their product; this is an independent record of how its REST API answers.