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.
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.
?fieldscan change the value of a field you did ask for, not only drop ones you did not.?fields=display_type,urlon an Icon returnsurlas""; addingimage_datato the same request returns the realdata:image/png;base64URI, and omitting?fieldsreturns 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
relationshipsas{data, links}under the default, underapi3_arrayand underapi3_hash, the two 200_searchrows 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+jsonand...api3_hash+jsonbelong on POST/entity/<type>/_search(and_summarize), which rejectsapplication/jsonwith 415 (probe 020). What the vendor type selects is thefilterssyntax 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
linksis normalised. Everything measured in this corpus was measured on/api/v1and transfers to/api/v1.1unchanged.linksecho the prefix you called. A client that starts on/api/v1.1stays there throughlinks.next; there is no silent downgrade to follow, and no rewriting to guard against (006_pagination).api_versionin the root document reports the prefix that served the request,1.0or1.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/v1serves the same 62 operations, and there is no listing of valid prefixes./api/v1.2and/api/v2answer 404 code 103 withdetailnull, 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[].idthat changes on every call, and/spec.jsoncontains the literal/api/v1.1underserverswhichever 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/v1reflects whateverOriginreaches it. There is no allowlist to be on and nothing to register:null, anhttp://localhostorigin and the stringbananaall come back inaccess-control-allow-origin, each withaccess-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-Methodis 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/v1answers 200 with the echo, including one no route serves; a path outside/api/v1answers 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-Methodone ofGETPOSTPUTPATCHDELETEaccess-control-allow-methods: GET, POST, PUT, PATCH, DELETEAccess-Control-Request-Method: TRACEorBREW200 with every CORS header dropped, which fails the preflight Access-Control-Request-Headersdrawn fromauthorization,content-type,accept,accept-language,content-language,range,originthe same string echoed back one name outside that set, even beside a legal one 200 with every CORS header dropped A page may send
Authorizationand theapplication/vnd+shotgun.api3_hash+jsoncontent type (probe 004), and nothing else:cache-control,if-none-matchandx-requested-withfail the preflight and the request is never made. The content type is not a safelisted value, so every call is preflighted, andaccess-control-max-age: 3600is 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).etagandx-request-idare on the response and unreadable from a page, andif-none-matchis 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/v1direct. Mint a token, read, filter, write. The _uploadflow then leaves the site for a presigned host, whose own answer is not measured here/internal_api/*: the App Session Launcher and the sessionthrough 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 throughfetch, and pixels read back off a canvas are taintedSo a page signs a person in by having its own server make the two launcher calls of probe 052, opening the returned
urlin a tab for the person to approve, and then minting the bearer from the page, sincePOST /auth/access_tokenis one of the calls it may make. The alternative, a page holdingclient_credentials, ships the script key to every visitor.
corpus/findings/062_cors.md