# xBid API — v1 A REST API for creating and managing quotes (jobs), shots, assets and tasks programmatically, so another system can file a priced quote into xBid without anyone opening the UI. Base URL: `https://xbid.twotalesvfx.com/api/v1` (locally, `http://localhost:3000/api/v1`) > This is the **inbound** API — other systems calling xBid. > For the outbound direction, where xBid pulls shots from the review app, see > [`GET_SHOTS.md`](GET_SHOTS.md). --- ## Contents - [Authentication](#authentication) - [Managing keys](#managing-keys) - [Conventions](#conventions) - [Errors](#errors) - [Quick start](#quick-start) - [Endpoints](#endpoints) - [Quotes](#quotes) - [Shots](#shots) - [Assets](#assets) - [Tasks (line items)](#tasks-line-items) - [Reference data](#reference-data) - [Pricing](#pricing) - [Shot codes](#shot-codes) --- ## Authentication Every request needs an API key: ``` Authorization: Bearer xbid_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` `X-API-Key: ` is accepted as an alternative for clients that cannot set an `Authorization` header. Keys carry scopes: | Scope | Grants | |---------|--------------------------------------------------------| | `read` | All `GET` endpoints | | `write` | `POST`, `PATCH` and `DELETE` — creating and changing data | A read-only key sent to a write endpoint gets `403 forbidden`, not `401`. Check a key works, and see what it may do: ```bash curl -s https://xbid.twotalesvfx.com/api/v1 \ -H "Authorization: Bearer $XBID_API_KEY" ``` That endpoint echoes back the key's name and scopes, and lists every available route — it is the first thing to call when wiring up an integration. ### Managing keys Keys are created in the app: **Settings → API Keys → New Key**. Only signed-in **admin** users can mint, revoke or delete them. A new key's plaintext is shown **once**, at creation. Only a SHA-256 hash and a short display prefix are stored, so a lost key cannot be recovered — revoke it and issue another. Revoking takes effect on the very next request. Each key records when it was last used (to the nearest minute), so an unused integration is easy to spot before you revoke it. --- ## Conventions **Response envelope.** Success is `{ "data": … }`, with `{ "meta": … }` added on list endpoints. Failure is `{ "error": { … } }`. The two never mix. ```json { "data": { "id": "clx…", "projectTitle": "Hero Sequence" } } ``` ```json { "data": [ … ], "meta": { "total": 42, "limit": 25, "offset": 0, "returned": 25 } } ``` **Decimals are strings.** Money, hours, rates, multipliers and percentages come back as JSON strings (`"10000.0000"`), not numbers. xBid prices everything with exact decimal arithmetic, and sending floats over the wire would reintroduce the drift that avoids. Parse them with a decimal library, not `parseFloat`, if you intend to do arithmetic. On input, numbers and numeric strings are both accepted. **Quotes are addressable by code.** Anywhere a quote id appears in a path, its unique `projectCode` works too: ``` GET /api/v1/quotes/clx8f3a… # by id GET /api/v1/quotes/HERO-SEQUENCE # by projectCode ``` **Quotes and jobs are the same thing.** A "quote" and a "job" are one record in xBid. Every `/api/v1/quotes/…` route is also mounted at `/api/v1/jobs/…`; use whichever name your side calls it. **Dates** accept `2026-03-01`, a full ISO 8601 timestamp, or epoch milliseconds, and are always returned as ISO 8601. **One or many.** Endpoints that add shots, assets or tasks accept either a single object or an array of them, up to 500 per request. The response mirrors what you sent: an object for an object, an array for an array. **PATCH is partial.** Omitted fields keep their stored values — a `PATCH` never clears a field by leaving it out. Where clearing is meaningful (`expiryDate`, `manualSubtotal`, …), send an explicit `null`. **Nothing is cached.** All responses are `Cache-Control: no-store`. --- ## Errors ```json { "error": { "code": "validation_failed", "message": "Request body failed validation.", "details": { "projectTitle": ["projectTitle is required"] } } } ``` | Status | `code` | Meaning | |--------|---------------------|-----------------------------------------------------------| | 400 | `invalid_request` | Malformed JSON, or a reference to something that does not exist | | 401 | `unauthorized` | Missing, unknown, revoked or expired key | | 403 | `forbidden` | Valid key, but missing the required scope | | 404 | `not_found` | No such quote, shot, asset or task | | 405 | — | Method not supported on that route | | 409 | `conflict` | Unique clash — a duplicate `projectCode` or shot code | | 422 | `validation_failed` | Body parsed, but failed validation; see `details` | | 500 | `internal_error` | Something broke; details are logged server-side, not returned | `details` on a validation failure maps each field path to its messages. --- ## Quick start Create a complete, priced quote in one request — client, shots, assets and tasks included: ```bash curl -X POST https://xbid.twotalesvfx.com/api/v1/quotes \ -H "Authorization: Bearer $XBID_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "projectTitle": "Hero Sequence VFX", "showId": "HERO", "client": { "companyName": "Big Screen Pictures", "email": "accounts@bigscreen.example" }, "currency": "ZAR", "shots": [ { "scene": "010", "description": "Hero wide establishing shot", "durationSeconds": 5, "complexityMultiplier": 1.5, "lineItems": [ { "taskName": "Compositing", "rateCardName": "Senior Compositor", "quantityHours": 24 }, { "taskName": "Roto", "dailyRate": 4000, "numDays": 2, "numArtists": 2 } ] }, { "scene": "010", "description": "Hero close-up" }, { "scene": "020", "description": "Crowd extension", "lineItems": [ { "taskName": "CG Crowd", "rateCardName": "FX TD", "numDays": 5 } ]} ], "assets": [ { "assetName": "Hero Spaceship", "assetType": "Vehicle", "lineItems": [ { "taskName": "Modelling", "rateCardName": "Modeller", "numDays": 10 }, { "taskName": "Texturing", "rateCardName": "Texture Artist", "numDays": 6 } ] } ] }' ``` The whole payload is applied in **one transaction** — if any part is rejected, nothing is created. The response is the full quote graph, with every task priced and every shot coded (`HERO_010_0010`, `HERO_010_0020`, `HERO_020_0010`). Then open it in the UI at `/jobs/{id}`, or export it: - PDF: `GET /api/pdf/{id}` - CSV shotlist: `GET /api/csv/shots/{id}` (add `?detail=tasks` for a full cost breakdown) Those two export routes use the app's session login, not an API key. --- ## Endpoints ### Quotes #### `GET /api/v1/quotes` List quotes, newest first. | Query | Default | Notes | |------------|---------|------------------------------------------| | `limit` | 25 | 1–100 | | `offset` | 0 | For paging | | `status` | — | `DRAFT` `SENT` `APPROVED` `REJECTED` `ARCHIVED` | | `clientId` | — | Filter to one client | | `search` | — | Matches project title or code, case-insensitive | Each row includes its client and a `_count` of shots, assets, supervisions and previses. `meta.total` is the count before paging. #### `POST /api/v1/quotes` Create a quote. Requires `projectTitle`, plus either `clientId` or `client`. | Field | Default | |---|---| | `projectTitle` | **required** | | `clientId` *or* `client` | **required** — see below | | `projectCode` | derived from the title (and `showId`), with `-2`, `-3`… appended to stay unique | | `showId` | derived from `projectCode`; max 10 chars, letters/numbers/underscores | | `jobType` | `STANDARD`; `EPISODIC` puts the episode in shot codes | | `status` | `DRAFT` | | `currency` | the studio's default currency (Settings), else `ZAR` | | `issueDate` | today | | `markupPercent`, `taxPercent`, `discountPercent` | the studio's quote defaults (Settings), else `0` | | `productionFeePercent`, `pullManagementFeePercent` | `0` | | `expiryDate`, `startDate`, `estimatedCompletionDate` | unset | | `description`, `internalNotes`, `termsAndConditions`, `customHeaderHtml`, `customFooterHtml` | unset | | `shots`, `assets` | none — see [Shots](#shots) and [Assets](#assets) for the item shape | **Attaching a client.** Either pass a `clientId`, or pass a `client` object and xBid will match an existing client on `companyName` (case-insensitive) and create one only if there is no match. This is the only place the API writes to clients. ```json { "client": { "companyName": "Big Screen Pictures", "contactPerson": "Dana Okafor" } } ``` A caller-supplied `projectCode` that is already taken returns `409 conflict`; omit it and xBid picks a free one. Returns `201` with the full quote graph. #### `GET /api/v1/quotes/{idOrCode}` The full quote: client, shots, assets, supervisions, previses, every task with its rate card, and the payment schedule with its milestones. #### `PATCH /api/v1/quotes/{idOrCode}` Update quote-level fields — any field from `POST` except `shots`, `assets` and `client`. Shots and assets have their own endpoints. ```bash curl -X PATCH .../api/v1/quotes/HERO-SEQUENCE \ -H "Authorization: Bearer $XBID_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "SENT", "expiryDate": "2026-04-30" }' ``` #### `DELETE /api/v1/quotes/{idOrCode}` Deletes the quote and everything that cascades from it — shots, assets, tasks, supervisions, previses, the payment schedule and **its invoices**. There is no undo. #### `POST /api/v1/quotes/{idOrCode}/recalculate` Recompute and re-store every task total on the quote, returning the refreshed quote. The write endpoints already price each task as it is created, so this is for repairing rows that arrived another way: a restored backup, a direct SQL edit, or a `complexityMultiplier` changed without touching the tasks underneath it. --- ### Shots A shot belongs to a quote and holds tasks. #### `GET /api/v1/quotes/{idOrCode}/shots` The quote's shots in display order, each with its tasks. #### `POST /api/v1/quotes/{idOrCode}/shots` Add one shot, or an array of them. | Field | Default | |---|---| | `scene` | `"SC"`; upper-cased | | `shotCode` | generated — see [Shot codes](#shot-codes) | | `episode` | unset; used in codes on `EPISODIC` quotes. Upper-cased | | `shotGroup` | unset — a grouping label for non-episodic quotes | | `description`, `notes` | unset | | `durationSeconds` | unset | | `complexityMultiplier` | `1` — 0.1 to 10, multiplies every task on the shot | | `lineItems` (alias `tasks`) | none | ```bash curl -X POST .../api/v1/quotes/HERO-SEQUENCE/shots \ -H "Authorization: Bearer $XBID_API_KEY" \ -H "Content-Type: application/json" \ -d '[ { "scene": "030", "description": "Matte painting", "complexityMultiplier": 2 }, { "scene": "030", "description": "Set extension", "lineItems": [ { "taskName": "Paint", "rateCardName": "Senior Compositor", "quantityHours": 16 } ]} ]' ``` Returns `201`. Codes continue from the highest number already used in that scene, so adding shots to an existing quote never collides. #### `GET` · `PATCH` · `DELETE /api/v1/shots/{id}` Fetch, update or delete one shot. `PATCH` also accepts `shotNumber` and `sortOrder`. An existing `shotCode` is **left alone** unless you send a new one — changing a shot's scene does not silently renumber it, because codes are usually shared with other systems by then. Deleting a shot deletes its tasks. --- ### Assets An asset belongs to a quote and holds tasks. Unlike shots, assets have no codes or numbering. #### `GET /api/v1/quotes/{idOrCode}/assets` #### `POST /api/v1/quotes/{idOrCode}/assets` One asset or an array. | Field | Default | |---|---| | `assetName` | **required** | | `assetType` | unset — free text, e.g. `Vehicle`, `Character`, `Environment` | | `description`, `notes` | unset | | `complexityMultiplier` | `1` — 0.1 to 10 | | `lineItems` (alias `tasks`) | none | ```bash curl -X POST .../api/v1/quotes/HERO-SEQUENCE/assets \ -H "Authorization: Bearer $XBID_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "assetName": "Villain Mech", "assetType": "Character", "complexityMultiplier": 1.25, "lineItems": [{ "taskName": "Rigging", "rateCardName": "Rigger", "numDays": 8 }] }' ``` #### `GET` · `PATCH` · `DELETE /api/v1/assets/{id}` --- ### Tasks (line items) A task is one costed row under a shot or an asset. #### `GET` · `POST /api/v1/shots/{id}/line-items` #### `GET` · `POST /api/v1/assets/{id}/line-items` `POST` takes one task or an array, appended after any existing ones. | Field | Default | |---|---| | `taskName` | **required** | | `artistRole` | unset | | `rateCardId` *or* `rateCardName` | unset — `rateCardName` matches case-insensitively | | `dailyRate` | the rate card's `defaultDailyRate`, else `0` | | `hoursPerDay` | the rate card's `hoursPerDay`, else `8` (1–24) | | `quantityHours` | `0` | | `numDays` | `0` | | `numArtists` | `1` | | `manualHourlyRate` | unset — overrides the derived hourly rate | | `manualSubtotal` | unset — overrides the computed subtotal outright | | `markupPercent`, `discountPercent`, `taxPercent` | `0` | | `notes` | unset | An unknown `rateCardId` or `rateCardName` is rejected with `400`, rather than quietly costing the task at zero. #### `GET` · `PATCH` · `DELETE /api/v1/line-items/{id}` `PATCH` reprices the task from the merged result of its stored values and your changes, using its parent's complexity multiplier. Send `null` for `manualHourlyRate` or `manualSubtotal` to drop an override and go back to the derived figure. --- ### Reference data Both are read-only. #### `GET /api/v1/clients` Find a `clientId` before creating a quote. Supports `limit`, `offset` and `search` (company name or contact person). #### `GET /api/v1/rate-cards` List rate cards to quote against. Active cards only by default; add `includeInactive=1` for all. Supports `search` (name or department). --- ## Pricing Every task is priced on the server as it is written, so stored totals always match what the PDF and CSV exports show. The arithmetic is exact decimal, never floating point. **Hourly rate** — `manualHourlyRate` if given, otherwise `dailyRate ÷ hoursPerDay`. **Subtotal** — the first of these that applies: 1. `manualSubtotal`, used as-is (the complexity multiplier does **not** apply) 2. `numDays > 0` → `numDays × numArtists × dailyRate × complexity` 3. otherwise → `quantityHours × hourlyRate × numArtists × complexity` So a task is costed **by days** as soon as `numDays` is non-zero, even if `quantityHours` is also set. Send one or the other. **Adjustments** apply in a fixed order, each on the running figure: ``` subtotal + markupPercent → markupAmount − discountPercent → discountAmount (on the marked-up amount) + taxPercent → taxAmount (on the post-discount amount) = total ``` A task at 100.00 with 10% markup, 10% discount and 15% tax totals **113.85**: `100 → 110 → 99 → 113.85`. Note the discount is 11.00, not 10.00, because it applies after markup. The quote-level `markupPercent`, `taxPercent`, `discountPercent`, `productionFeePercent` and `pullManagementFeePercent` are applied on top when the quote is totalled for presentation; they do not change stored task totals. --- ## Shot codes Codes are generated per scene, continuing from the highest number already used, and step by 10 so shots can be inserted between existing ones later: ``` STANDARD {showId}_{scene}_{number} HERO_010_0010 EPISODIC {showId}_{episode}_{scene}_{number} HERO_103_010_0010 ``` Numbering is scoped **per scene**, and per episode as well on episodic quotes — so scene `010` and scene `020` each start at `0010`, and episode `103` numbers independently of episode `104`. `showId`, `scene` and `episode` are upper-cased, and the number is padded to 4 digits. Supply an explicit `shotCode` to preserve codes from another system; it is upper-cased and must be unique within the quote, or you get `409 conflict`. The UI generates codes the same way, through the same shared module, so shots added either route stay consistent.