SG Ground Truth

batch

Apply many creates, updates and deletes in one atomic call, and match the results back to the requests

API

Call

import json
import sys

sys.path.insert(0, "src")                       # or PYTHONPATH=src
from sg_groundtruth.client import FPT           # adds the bearer token and the /api/v1 prefix
from sg_groundtruth.env import load

c = FPT.from_env(load("."))                     # FPT_API_SITE_URL, FPT_API_SCRIPT_NAME, FPT_API_API_KEY
PROJECT_ID = 1234                               # the caller supplies this


def run(requests_):
    """One atomic call. Plain application/json; the vendor type _search needs 415s here."""
    r = c.post("/entity/_batch", json={"requests": requests_})
    if not r.ok:
        raise SystemExit(json.dumps(r.json()["errors"], indent=2))   # nothing was applied
    return r.json()["data"]


def row_id(row):
    """A create or update row nests the record under `data`; a delete row is flat."""
    return row.get("data", row)["id"]


# 1. A batch cannot reference an id it creates, so the rows the rest of the batch points at
#    go in a call of their own.
shot_id = row_id(run([{"request_type": "create", "entity": "Shot",
                       "data": {"project": {"type": "Project", "id": PROJECT_ID},
                                "code": "sh010", "description": "batch 1"}}])[0])

# 2. Two creates and an update of another entity type, in one call. `entity` is the singular
#    schema name and the id key is `record_id`.
reqs = [
    {"request_type": "create", "entity": "Version",
     "data": {"project": {"type": "Project", "id": PROJECT_ID}, "code": "v001",
              "entity": {"type": "Shot", "id": shot_id}, "sg_status_list": "rev"}},
    {"request_type": "create", "entity": "Version",
     "data": {"project": {"type": "Project", "id": PROJECT_ID}, "code": "v002",
              "entity": {"type": "Shot", "id": shot_id}, "sg_status_list": "rev"}},
    {"request_type": "update", "entity": "Shot", "record_id": shot_id,
     "data": {"description": "batch 2"}},
]
out = run(reqs)

# 3. Results are one row per request, in request order. Pair them by position: two rows can share
#    a code, and matching on one would mislink them.
ids = [row_id(row) for row in out]
for req, i in zip(reqs, ids):
    print(req["request_type"], req["entity"], i)

# 4. Delete takes record_id and no data. Mixed types are fine in one call.
run([{"request_type": "delete", "entity": "Version", "record_id": ids[0]},
     {"request_type": "delete", "entity": "Version", "record_id": ids[1]},
     {"request_type": "delete", "entity": "Shot", "record_id": shot_id}])

Response

1. batch 1  200  [{"data": {"type": "Shot", "id": 7557, "attributes": {...}, "links": {...}}}]
2. batch 2  200, three rows, in request order
     row 0  create  keys ['data']                    Version 29926  code 'v001'
     row 1  create  keys ['data']                    Version 29927  code 'v002'
     row 2  update  keys ['data', 'links', 'status'] Shot    7557   description 'batch 2'
   row 0 in full, trimmed:
     {"data": {"type": "Version", "id": 29926,
               "attributes": {"code": "v001", "sg_status_list": "rev", "cached_display_name": "v001",
                              "created_at": "2026-09-02 19:07:26 UTC", "viewed_by_current_user": "unread",
                              "updated_at": "2026-09-02 19:07:26 UTC", "open_notes_count": 0,
                              "sg_version_type": "Type A"},
               "relationships": {"entity": {...}, "project": {...}, "tasks": {"data": []}, ...},
               "links": {"self": "/api/v1/entity/versions/29926"}}}
3. step 3 prints
     create Version 29926
     create Version 29927
     update Shot 7557
4. delete  200, one flat row per request. One of them verbatim:
     {"request_type": "delete", "type": "Version", "id": 29941,
      "uuid": "906c4522-a701-11f1-b496-0a58a9feac02", "did_delete": true}

Notes

  • A batch cannot use an id it creates. Every way of pointing request 1 at request 0's row was rejected, and the failure is the whole batch, so nothing at all lands.

    entity value sent result
    {"type": "Shot", "id": "$0"} 400 Invalid field value, update failed [5 - Update failed for [Version.entity]: Value is not legal.]
    {"type": "Shot", "id": -1} 400, the same
    {"type": "Shot", "id": "0"} 400, the same
    {"type": "Shot", "id": "u1"}, request 0 sent with "uuid": "u1" 400, the same
    {"type": "Shot", "uuid": "u1"}, request 0 sent with "uuid": "u1" 400 Invalid field value, update failed [5 - Update failed for [Version.entity]: Invalid statement.]

    The uuid a delete row returns is generated per request and is not an input. Build a dependent graph as one batch per level: create the parents, read their ids out of the response, substitute, then send the children. Steps 1 and 2 above are that sequence.

  • Results are in request order, one row per request, interleaved by neither id nor type. A batch of [update 29926, create, update 29927, create, update Shot 7557] answered in exactly that order, so zip(requests, response["data"]) is correct and no key matching is needed.

  • Two creates sending the same code came back as two rows distinguished only by position and by the new ids, 29930 and 29931.

  • One failing request rolls back every other one. Each round below sent a good create, one bad request, and an update of an existing row whose description read before:

    the bad request status after it
    update record_id 999999999 404 Entity of type [Version] with id=999999999 does not exist. 0 rows created, description still before
    delete record_id 999999999 404, the same 0 rows created, description still before
    create with sg_not_a_field 400 Invalid field value, update failed [2 - Invalid field name: field [Version.sg_not_a_field] does not exist or user does not have access permission.] 0 rows created, description still before
    create with sg_status_list: not_a_status 400 Invalid field value, update failed [5 - Update failed for [Version.sg_status_list]: 'not_a_status' is not a valid status. Valid statuses: 'na', 'rev', 'vwd', 'apr', 'custom', 'fin', 'ip', 'clsd', 'cmpt', 'cfrm', 'pndad', 'pndl', 'pndvs', 'part', 'pass', 'pndng'.] 0 rows created, description still before

    The rollback is the reason to use the endpoint. A timeout is not covered by it: see the size note.

  • A batch create skips the validation a single create applies, and the row it makes is unreadable. POST /entity/versions with no project is 400 API create() missing 'project' attribute: {"code" => "v001"}. The same create inside a batch answered 200 with id 29932 and a create row holding no project relationship.

  • GET /entity/versions/29932 then answered 404 Version: 29932 not found, and a site-wide POST /entity/versions/_search on its code returned 0 rows. DELETE /entity/versions/29932 answered 204, so the row exists and only the id from the create response can reach it.

  • Validate a batch payload yourself; a 200 is not proof the row is addressable. A link to an id that does not exist is rejected on both paths, 400 Update failed for [Version.entity]: Value is not legal.

  • Size. No cap was found. A requests array of 5001 was validated in full, answering one data hash containing field/value pairs is required for the given request per element.

  • On the probed site a committing batch of 200 answered in 11.7s, 500 in 31.0s and 1001 in 47.7s on one run and not at all on another, where the client gave up at its own 60s read timeout. All 1001 rows had committed anyway.

  • A read timeout tells you nothing about what landed, and there is no request id to ask about, so keep a batch inside the response window, around 200 requests, and make each chunk re-runnable by reading back on code before resending.

  • The contract, one 400 at a time. Every rejection below names what it wanted.

    sent result
    Content-Type: application/vnd+shotgun.api3_array+json 415 Unsupported Content-Type 'application/vnd+shotgun.api3_array+json', {"content_type": "Content-Type must be one of: 'application/json'."}
    a top-level array 400 Invalid JSON body. Expected Hash but received Array.
    {"entity": "Version"} 400 Request Parameters invalid. {"requests": ["requests is missing"]}
    {"requests": []} 200 {"data": []}
    a request with no entity 400 {"requests": {"0": {"entity": ["entity is missing"]}}}
    a create with no data 400 {"data": ["data hash containing field/value pairs is required for the given request"]}
    "request_type": "read" 400 {"requests": {"0": {"request_type": ["request_type must be one of: create, update, delete"]}}}
    "entity": "versions", the URL slug 400 Invalid entity type: entity type [] does not exist.
    delete with no record_id, or with entity_id 404 Entity of type [Version] with id=0 does not exist.
  • delete in a batch and the DELETE verb do the same thing and report it differently.

    body after it
    batch delete 200 {"request_type": "delete", "type": "Version", "id": N, "uuid": "...", "did_delete": true} GET that id 404s
    DELETE /entity/versions/N 204, 0 bytes GET that id 404s

    Deleting an already deleted id inside a batch is 404 Entity of type [Version] with id=N does not exist. and takes the rest of the batch down with it, so a delete pass is not idempotent.

  • Response shape differs by request_type: a create row is the thin create subset, an update row is the whole record wrapped with links and status, a delete row is flat (probe 024 for the field-level table). ?fields on /entity/_batch is accepted and ignored, as on every other write (probe 024), and no row resolves a dotted path, so re-read for those.

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.