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 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:
https://api.supacolor.com, tokens https://auth.supacolor.com/realms/sc/protocol/openid-connect/token · spechttps://api.supacolour.co.nz, tokens https://auth.supacolour.co.nz/realms/sc/protocol/openid-connect/token · spechttps://api.supacolour.co.uk, tokens https://auth.supacolour.co.uk/realms/sc/protocol/openid-connect/token · spechttps://api.supacolour.eu, tokens https://auth.supacolour.eu/realms/sc-eu/protocol/openid-connect/token · specOAuth2 client credentials. POST https://auth.supacolour.com.au/realms/sc-au/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.
Each step produces something the next one needs. Call them in this order:
GET /PriceCodes/processes — the processes your account can order (Wearable, SupaDTF, Sub
Block …). Start here: availability is per-account, so never hardcode it.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.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.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.GET /Jobs/{jobNumber} — status, money, tracking. GET /Jobs/active returns
everything open in one call; prefer it over polling jobs individually.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.
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:
permissions.canEdit — whether PATCH /Jobs/{jobNumber} will be accepted. You can
amend the description, your PO number, comments, the requested ship date and the must-ship flag.
When it is false, permissions.lockedReason says why.isCancelable — whether POST /Jobs/{jobNumber}/cancel will be accepted. Once the job
is in production it will not be, and you get a 409.⚠️ 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.
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.
items[].itemType selects the line type. "PriceCode" for a new print, "Asset" for a
reorder, "Stock" for stocked goods — and it decides what code means (full price code, asset
tag, or stock code respectively). The schema does not enumerate these values; this list is the
documentation.POST /Jobs usually means an invalid payload, not an outage. Empty items, an
unroutable delivery address, a nonexistent asset code or a bad sizeQuantities key all surface
as an opaque 500. Fix the payload before retrying; validateOnly with a well-formed payload
returns real validation messages. If you need to rule out the other possibility, https://supacolour.co.nz/status.json reports what our own monitoring sees for this region.GET /Lookups/job-statuses,
processes from GET /Lookups/process-codes, countries and states from GET /Lookups/countries.countryCodeISO2 is two characters — "US", not "USA". A three-letter code is rejected.These are the things that are not visible from the schema and that cost time when discovered the hard way.
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.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.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.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.WE_LC:Wearable-4" x 4" for geometry; read sizeWidth, sizeHeight and sizeUnit.countryCodeISO2 is two characters. "US", never "USA".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.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.Things no single endpoint owns, and the ones most often got wrong.
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 forColours. 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 thenamethe API gave you.
⚠️
attributesis a hierarchy, not one flat shape. The base carriesname,label,type,required,description,enumerableValues,isMetaDataAttributeandassetAttributeName; text attributes addmaxLengthandminLength. The specification does not discriminate the subtypes, so branch ontyperather 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.
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
fromis 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.
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
searchTextorsortByinstead. The specification lists the exact query parameters per operation — treat this as the pattern and the reference as the authority.
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 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.
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.
32 operations, every one of them callable with a customer credential. If it is documented here, your token can use it.
GET /PriceCodes/{priceCode} — getPriceCodesByPriceCodeGET /PriceCodes/price-codes — getPriceCodesPricecodesGET /PriceCodes/processes — getPriceCodesProcessesvalidateOnly: true dry-runs a payload so you can build without creating real jobs.POST /Jobs — createJobsGET /Jobs/{jobNumber} — getJobsByJobNumberPATCH /Jobs/{jobNumber} — updateJobsByJobNumberPOST /Jobs/{jobNumber}/cancel — createJobsByJobNumberCancelGET /Jobs/active — getJobsActiveGET /Jobs/delivery-options — getJobsDeliveryoptionsGET /Jobs/earliest-ship-date — getJobsEarliestshipdateGET /Assets — getAssetsGET /Assets/{assetCode}/jobs — getAssetsByAssetCodeJobsGET /Assets/{assetTag} — getAssetsByAssetTagGET /Assets/{assetTag}/files — getAssetsByAssetTagFilesGET /Assets/global — getAssetsGlobalGET /Assets/types — getAssetsTypesGET /account confirms which account a set of credentials belongs to.GET /account — getAccountGET /account/shipment-settings — getAccountShipmentsettingsGET /account/transactions — getAccountTransactionsGET /account/user — getAccountUserGET /account/users — getAccountUsersGET /account/users/{userId} — getAccountUsersByUserIdGET /Lookups/countries — getLookupsCountriesGET /Lookups/countries/{countryCode}/states — getLookupsCountriesByCountryCodeStatesGET /Lookups/job-statuses — getLookupsJobstatusesGET /Lookups/process-codes — getLookupsProcesscodesGET /Lookups/size-sets — getLookupsSizesetsGET /Stock — getStockGET /Stock/{code} — getStockByCodeGET /promocode/validate/{code} — getPromocodeValidateByCodeGET /stock/{stockItemId}/shipping — getStockByStockItemIdShippingGET /stock/shipping — getStockShippingEvery 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.