Supacolour API — guide for coding agents

Machine-readable spec: https://0.0.0.0:8080/developers/openapi.json Everything in one fetch: https://0.0.0.0:8080/developers/llms-full.txt Is it us or you: https://0.0.0.0:8080/status.json (JSON) · https://0.0.0.0:8080/status (page)

Order heat transfers programmatically: quote, submit, attach artwork, then track to despatch.

This is the same API the Supacolour dashboard runs on — not a reduced side-door version. Anything the dashboard does, your system can do.

This document is region-specific

This copy of the spec targets https://api.supacolour.co.nz. Your account exists in exactly one region, and your credentials only work against that region's host — which host that is comes with your credentials when they are issued (it is not discoverable from the API itself). If your account is in a different region, use that region's own copy of this document:

Authenticate

OAuth2 client credentials. POST https://auth.supacolour.co.nz/realms/sc/protocol/openid-connect/token with client_id and client_secret (form-encoded) returns a bearer token; send it as Authorization: Bearer <token> to https://api.supacolour.co.nz. Tokens last 10 minutes. Credentials are issued per wholesale account and see only that account's data.

⚠️ The token endpoint is on this region's auth host, which is not the API host — every call after it goes to https://api.supacolour.co.nz.

Create them yourself in the dashboard, under Integrations → API access. You get a key ID and a secret; the secret is shown once, so copy it then.

The ordering flow

Each step produces something the next one needs. Call them in this order:

  1. GET /PriceCodes/processes — the processes your account can order (Wearable, SupaDTF, Sub Block …). Start here: availability is per-account, so never hardcode it.
  2. GET /PriceCodes/price-codes — price codes for those processes, with sizes and prices at your tier. The code returned is what goes in a job line.
  3. GET /Jobs/delivery-options and GET /Jobs/earliest-ship-date — the delivery methods available to you, and the soonest despatch for the processes you're ordering. The method code goes in deliveryAddress.deliveryMethod.
  4. POST /Jobs — create the job, with the artwork on each line as externalArtworkUrl. ⚠️ Send "validateOnly": true first: it runs the full validation path and returns the errors without creating anything. Build against that.
  5. GET /Jobs/{jobNumber} — status, money, tracking. GET /Jobs/active returns everything open in one call; prefer it over polling jobs individually.

Artwork goes on the job line

Put a URL to the artwork in externalArtworkUrl on each PriceCode line. That is how our own ordering works, and it means one request places the order and delivers the art together.

The URL must stay reachable until the job is in production — we fetch it, we do not hold a copy of your link. A signed URL is fine as long as it outlives the job reaching the factory.

After you have ordered

A job is not frozen the moment you submit it. GET /Jobs/{jobNumber} tells you what you may still do, and you should read that rather than assume:

⚠️ They are independent, and they do not change together. A job already in production is commonly canEdit: true and isCancelable: false — you can still fix the PO number, but the order is past the point of being called back. Read both, rather than inferring one from the other or caching an earlier answer.

Reordering costs you nothing

An asset is a print already made. GET /Assets lists yours. To run it again, send a job line with "itemType": "Asset" and the asset tag as code — no artwork upload, no colour re-approval, and the result matches the previous run.

Conventions that will bite you otherwise

Working rules

These are the things that are not visible from the schema and that cost time when discovered the hard way.

  1. Build against validateOnly. POST /Jobs with "validateOnly": true runs the entire authentication and validation path and creates nothing, answering "Validated with NO Errors. Job NOT created." when the payload is good. Use it for every iteration until the payload is right.
  2. A 500 from POST /Jobs usually means your payload, not an outage. Measured triggers: an empty items array, a delivery address that cannot be routed, an unknown asset code, a missing dateDue, or a sizeQuantities key that is not in the variant's size run. Retrying unchanged will not help. To rule out the other possibility, https://0.0.0.0:8080/status.json reports what our own monitoring sees for this region — including failures that start with the systems we depend on.
  3. items[].itemType decides what code means. "PriceCode" takes a full price-code string, "Asset" takes an asset tag, "Stock" takes a stock code. The schema does not enumerate these values.
  4. Never hardcode an enum. Job statuses come from GET /Lookups/job-statuses, processes from GET /Lookups/process-codes, countries and states from GET /Lookups/countries. New values are added over time and must not break your integration.
  5. A price code's embedded size is a label, not a measurement. Do not parse WE_LC:Wearable-4" x 4" for geometry; read sizeWidth, sizeHeight and sizeUnit.
  6. countryCodeISO2 is two characters. "US", never "USA".
  7. Prices are per-account. Everything you read is at that account's tier. Never cache pricing across accounts or show it to another customer.
  8. Reordering is the cheap path. An existing asset reordered by tag reproduces the previous run with no artwork to supply and no colour re-approval.
  9. Artwork goes on the job line as externalArtworkUrl. There is no separate upload step. The URL must stay reachable until the job is in production — we fetch it rather than holding your link.
  10. Check the job before amending or cancelling it. GET /Jobs/{jobNumber} returns permissions.canEdit (whether PATCH /Jobs/{jobNumber} will work, with permissions.lockedReason when it will not) and isCancelable (whether POST /Jobs/{jobNumber}/cancel will work). ⚠️ They are independent: a job in production is commonly still editable but no longer cancellable. Read both rather than inferring one from the other.
  11. Region is a property of the credentials. You cannot discover it by calling the API — you need the right regional host to get a token at all. It is stated when the credentials are issued.

Concepts

Things no single endpoint owns, and the ones most often got wrong.

Attributes: the form the API hands you

Every price code carries its own input fields. Read them, render them, send them back — never hardcode them.

A transfer is not just a size and a quantity. The factory needs to know what garment colour it is going on, which colours are in the design, what to call it on the job sheet. Those questions differ per product, and they change. So the API tells you what to ask.

Every price code returned by GET /PriceCodes/price-codes carries an attributes array. Each entry is a field definition: what to call it, what type of input, whether it is required, what the allowed values are. You build your form from that array, collect the answers as a flat object, and send it back on the job line.

// What a price code tells you to ask
{
  "priceCode": "WE_SM:Wearable-2.5\" x 2.5\"",
  "attributes": [
    {
      "name": "garment",
      "label": "What garment color will this be applied to?",
      "type": "select",
      "required": false,
      "isMetaDataAttribute": false,
      "enumerableValues": [
        { "value": "Light color fabric", "text": "Light color fabric", "selected": true },
        { "value": "Dark color fabric",  "text": "Dark color fabric",  "selected": false },
        { "value": "Mixed",              "text": "Mixed",              "selected": false }
      ]
    },
    {
      "name": "Colours",
      "label": "Colours in design",
      "type": "text",
      "maxLength": 100,
      "enumerableValues": null
    }
  ]
}

Render type: "select" as a dropdown of enumerableValues, type: "text" as a text input bounded by maxLength. Use label for the human, name for the key. Then send what the user chose, keyed by name:

// What you send back on the job line
{
  "itemType": "PriceCode",
  "code": "WE_SM:Wearable-2.5\" x 2.5\"",
  "quantity": 100,
  "externalArtworkUrl": "https://your-cdn.example/designs/riverside-crest.pdf",
  "attributes": {
    "garment": "Dark color fabric",
    "Colours": "Red, white, blue",
    "description": "Riverside Rugby crest"
  }
}

⛔ Never hardcode the key names. They are locale-specific: a US account is asked for Colors, an NZ or UK account for Colours. An integration that hardcodes either one silently drops the answer in the other region, and the job reaches the factory missing the colours. Always key off the name the API gave you.

⚠️ attributes is a hierarchy, not one flat shape. The base carries name, label, type, required, description, enumerableValues, isMetaDataAttribute and assetAttributeName; text attributes add maxLength and minLength. The specification does not discriminate the subtypes, so branch on type rather than assuming every field is present.

Two things to do before submitting. Truncate each value to its own maxLength — an over-long value is rejected at the far end, not helpfully. And keep the keys exactly as given: they are case-sensitive.

If the attributes include DG-X and DG-Y with isMetaDataAttribute: true, this is a custom-dimension product such as SupaDTF. Those two are not questions for the customer — they are the width and height, and they drive the price. Pass them to GET /PriceCodes/{priceCode}?dgX=…&dgY=… to get the price for that size.

Pull the catalogue once, not per order

Availability and prices are per-account. Fetch them on a schedule, store them, and read your own copy at checkout.

GET /PriceCodes/processes tells you what this account may order; GET /PriceCodes/price-codes returns the codes and their prices at that account’s tier. Neither is a public list — two customers calling the same endpoint get different answers.

Fetch both on a schedule and store them. Read your stored copy when someone is building an order. That is faster at checkout, it survives a blip on our side, and it is where the attribute definitions come from — you do not need a live call per order to know what to ask.

// Setup, then per-order
// Setup — on a schedule, e.g. nightly
const processes  = await api('/PriceCodes/processes');
const priceCodes = await api('/PriceCodes/price-codes');
await store.replaceCatalogue({ processes, priceCodes });

// Per order — from your own store, no network call
const code = await store.findPriceCode(chosenCode);
renderAttributeForm(code.attributes);

⛔ The first band’s from is the minimum orderable quantity, and it is never assumed to be 1 — a Wearable transfer starts at 10. Read it off the bands rather than hardcoding a floor, and reject a smaller quantity in your own basket instead of letting the customer reach checkout and be refused.

⚠️ Refresh it. Prices and availability change, and a stale catalogue quotes a number your customer will not be billed. Treat your copy as a cache with an expiry, not as a fixture you ship once.

The one thing not to cache is the ship date. GET /Jobs/earliest-ship-date accounts for factory workload and cut-off times, so it is a live answer by design.

Paging, sorting and filtering

The list endpoints share one set of query parameters. Learn them once.

Most list endpoints take the same shape, so you can write the plumbing once and reuse it for assets, jobs, stock and price codes.

// The shared parameters
?page=1&pageSize=25
?sortColumn=DateDue&sortDirection=Descending
?filter=riverside          # free-text search
?includeProcesses=WE,BL    # only these product families
?excludeProcesses=NA,NU    # everything but these

Paged responses carry totalCount, totalPages, hasNextPage and hasPreviousPage alongside the rows, so you can drive a pager without counting.

⚠️ Not every parameter applies to every endpoint, and a few use searchText or sortBy instead. The specification lists the exact query parameters per operation — treat this as the pattern and the reference as the authority.

Delivery addresses

The delivery method code and the country code both come from the API. Neither is free text.

A delivery address carries the usual lines plus two values you must not invent: the delivery method, which comes from GET /Jobs/delivery-options, and the country, which comes from GET /Lookups/countries.

// A delivery address
{
  "deliveryMethod": "COURIER",
  "companyName": "Riverside Print Co",
  "addressLine1": "12 Mill Road",
  "suburb": "Riverside",
  "city": "Christchurch",
  "postCode": "8011",
  "countryCode": "NZ",
  "contactName": "Sam Reed",
  "contactPhone": "+64 3 555 0142"
}

⛔ Carriers are per-region and per-account. A method code that works for one customer may not exist for another, so read the options for the account you are ordering for rather than shipping a hardcoded list.

For regions with states or provinces, GET /Lookups/countries/{countryCode}/states gives the accepted values. Send the value the lookup gives you — a full state name where the lookup returns a full state name, not an abbreviation you shortened yourself.

Tokens: get one, keep it, retry once

Tokens are short-lived. Cache until just before expiry rather than per request.

Exchange your client id and secret for an access token at your region’s token endpoint, then reuse it. Requesting a fresh token per API call is the most common thing a first integration gets wrong — it is slower and it is unnecessary.

// Cache with a margin
let cached = null;

async function token() {
  // A minute of margin: a token that expires mid-flight reads as a 401 you did not cause.
  if (cached && Date.now() < cached.expiresAt - 60_000) return cached.value;

  const res = await fetch(TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });

  const body = await res.json();
  cached = { value: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
  return cached.value;
}

⚠️ On a 401, drop the cached token and retry once. Retrying repeatedly with the same rejected token will not start working, and a loop against the token endpoint is how an integration gets itself rate limited.

⛔ Credentials belong to exactly one region and only work against that region’s host. A token minted in one region presented to another is a 401 that looks like a broken secret.

What happens after you submit

A job moves through production. What you may still change depends on where it has got to — and the job tells you.

Poll GET /Jobs/active for everything open on the account — one request regardless of how many jobs are running, which is what our own dashboard does. Use GET /Jobs/{jobNumber} when you need the full detail of one.

Do not infer from the status what you are allowed to do. The job carries that directly: read permissions.canEdit before offering an edit, and isCancelable before offering a cancel. ⚠️ They sit at different levels — canEdit is nested under permissions (with permissions.lockedReason explaining a refusal), isCancelable is on the job itself.

⚠️ Those two are independent, and it is measured, not assumed: a job already in production is commonly still editable but no longer cancellable. Treating either flag as a proxy for the other produces a button that fails when the customer presses it.

Statuses are reference data, not constants. Read them from GET /Lookups/job-statuses rather than hardcoding the strings — the set changes.

Poll on a sensible interval. Production is measured in days, so minutes between polls tells you everything a tighter loop would.

What you can call

32 operations, every one of them callable with a customer credential. If it is documented here, your token can use it.

Errors

Every failure carries an RFC 7807 problem body. detail explains this request; on a 400 the validation failures are in errors (or validationMessages for jobs). 401 means the token is missing, expired, or from another region. 403 means the operation is not available to your account's role — it will not start working on retry. 404 means no such record or it belongs to another account; the two are indistinguishable by design.