Adds a key-authenticated REST API at /api/v1 so another system can create and manage quotes without going through the UI. Endpoints cover quotes (list, create, read, update, delete, recalculate), shots, assets and line items, plus read-only clients and rate cards. POST /api/v1/quotes builds a whole priced quote -- client, shots, assets and tasks -- in a single transaction. Routes are also mounted under /api/v1/jobs, since a quote and a job are the same record here. Keys are stored as SHA-256 with a display prefix, so the plaintext is shown once at creation and is not recoverable. They carry read/write scopes, can be revoked (effective on the next request), and record when they were last used. Minting and revoking live in Settings and are restricted to signed-in admins. /api/v1 is exempt from the session proxy because it authenticates with a bearer key rather than a cookie -- without this, every API call was redirected to /login as a 307. It is not unprotected: each route handler requires a live, unrevoked key with the right scope. Shot numbering and shot-code construction move into lib/shot-codes.ts so the UI and the API cannot drift. This also fixes numbering restarting at 0010 when a scene was entered in a different case to the stored one: the lookup upper-cased the scene but the stored value was left as typed. Unspecified currency and markup/tax/discount on an API-created quote fall back to the studio's configured quote defaults. Decimal fields are serialised as strings to preserve the exact precision the app's decimal arithmetic maintains. Note: this needs `prisma migrate deploy` for the new api_keys table. Settings will error for admins until that runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
16 KiB
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.
Contents
Authentication
Every request needs an API key:
Authorization: Bearer xbid_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
X-API-Key: <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:
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.
{ "data": { "id": "clx…", "projectTitle": "Hero Sequence" } }
{
"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
{
"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:
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=tasksfor 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 and 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.
{ "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.
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 |
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 |
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 |
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:
manualSubtotal, used as-is (the complexity multiplier does not apply)numDays > 0→numDays × numArtists × dailyRate × complexity- 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.