Files
twotalesanimation 8a93a18bb9 Add REST API for quotes, shots, assets and tasks
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>
2026-11-11 09:10:45 +02:00

16 KiB
Raw Permalink Blame History

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=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 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:

  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.