Wheel Swap

The API

Ask for a wheel render from your own website or till system, check whether it is finished, and fetch it. Four routes, one credential, no SDK to install.

ეს გვერდი მხოლოდ ინგლისურადაა — ველების სახელები და კოდები ისედაც ინგლისურია. დაწერე ჩვენთან ქართულად, თუ რამე გაუგებარია.

Эта страница только на английском — названия полей и коды всё равно английские. Пишите нам по-русски, если что-то непонятно.

Getting a key

Sign in, open Settings, and press Create a key. Give it a name you will recognise later — till system, website — because that name and the first few characters are the only way to tell two keys apart afterwards.

Shown once

The key is displayed exactly once, when you create it. We store a hash of it and nothing else, so nobody here can tell you later what yours was — not support, not the owner. If you lose it, revoke it and make another.

A key spends the credits of the account that made it. Treat it like a password: keep it on your server, never in a web page, an app, or anything a customer can read. Revoking is instant — the next request carrying a revoked key is refused.

Authenticating

Every request carries the key in the Authorization header.

Authorization: Bearer ws_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

There is no way to pass a key in the URL, on purpose. A credential in a query string is written into every access log it passes through and sent onward in the Referer header.

Anything that is not a live key belonging to a usable account gets the same 401 — missing, malformed, unknown, revoked, or an account that has been closed. The answer does not say which, because a reply that distinguished them would let somebody with a wrong key learn that a right one exists.

Asking for a render

POST/api/v1/renders

Send the photographs as base64. A data: URL is accepted as well as raw base64, because that is what a browser's FileReader produces and an integration written from a browser example will send one.

curl https://wheel-swap.com/api/v1/renders \
  -H "Authorization: Bearer $WHEELSWAP_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "kind": "WheelSwap",
        "car":  "<base64 of the car photograph>",
        "wheel":"<base64 of the wheel you want fitted>"
      }'
FieldRequiredWhat it is
kindyes WheelSwap to change the wheels, BodyColour to repaint the car.
caryes The photograph of the car. Side or three-quarter on, wheels unobstructed.
wheelfor WheelSwap The wheel you want fitted, on its own, face-on and filling the frame.
wheelMarksno Which wheels to change, as x,y,r triples separated by semicolons, each a fraction of the image between 0 and 1. Omit it and the detector decides — see below.
wheelMarkno Where the disk is in your wheel photograph, one x,y,r triple.
optionno For BodyColour, the colour. A name or a hex value.
settingsno An object with any of rideHeight, backdrop, finishTexture, rimColour, rimColourHex. Omit it and nothing but the wheels changes.
Marks are worth sending

wheelMarks and wheelMark are optional and they are the single biggest difference between a good render and a poor one. Without them we work out where the wheels are, and on a busy photograph that goes wrong in a way you will recognise: one wheel changed and the other left alone. If your software knows where the wheels are — because a person drew on the photograph, or because you cropped it yourself — send that.

A successful request answers 202 Accepted. Nothing is rendered yet.

{
  "id": "01a0c3c6-97a2-743a-8157-09ae37157e09",
  "status": "queued",
  "kind": "WheelSwap",
  "credits": 1,
  "createdAt": "2026-09-21T11:42:07.113Z"
}

The Location header carries the same address as the id, so a client that follows it needs no string building of its own.

Checking whether it is finished

GET/api/v1/renders/{id}
{
  "id": "01a0c3c6-97a2-743a-8157-09ae37157e09",
  "status": "delivered",
  "kind": "WheelSwap",
  "credits": 1,
  "createdAt": "2026-09-21T11:42:07.113Z",
  "photoExpiresAt": "2026-09-22T11:42:07.113Z",
  "photoFirstUploadedAt": "2026-09-21T11:42:07.113Z",
  "result": "/api/v1/renders/01a0c3c6-97a2-743a-8157-09ae37157e09/image",
  "error": null,
  "errorCode": null
}

Poll until result is not null, rather than until status says a particular word. A render takes around half a minute; once every second or two is polite and well inside the limit.

photoExpiresAt is when the photographs you sent for this render are deleted, in UTC: 24 hours after they were last used, and 7 days after upload at the latest. Sending the same photograph again uses the stored one and moves this time. It is null once they are gone; the result is kept.

statusMeans
queuedAccepted, not started.
runningBeing rendered now.
deliveredFinished. result is set.
failedDid not work. error says why in a sentence you can show your customer, errorCode says it in a word you can branch on, and the credit has already been put back — you are not charged for a render you did not get.
errorCodeMeansWhat to do
quality_checkA render came back and did not pass our quality check, so it was not delivered. Send it again; a photo with the wheels clearly visible works best.
service_unavailableThe render could not be completed at that moment. Send it again in a few minutes.
no_visible_wheelsNo wheel in the photo is at least half visible, so there was nothing to swap. Use a side or three-quarter photo of the car — or send wheelMarks with where the wheels are, which is taken as the answer instead of looking.
unspecifiedDid not complete, for a reason recorded before these codes existed. Send it again.

Fetching the render

GET/api/v1/renders/{id}/image

Returns the JPEG. Carry the same Authorization header — this is not a public URL, and it is not a redirect, so an HTTP client that does not follow redirects works exactly as one that does.

Until the account has bought credits at least once, every render carries a small mark in the corner, here and in the studio alike. More credits lift it on renders made afterwards.

Credits and usage

GET/api/v1/account
{
  "credits":  { "plan": 8, "permanent": 120, "total": 128 },
  "lastThirtyDays": { "delivered": 46, "failed": 1, "inFlight": 0 },
  "limits":   { "rendersPerHour": 120, "readsPerHour": 3600 }
}

Two pots, because they behave differently. Plan credits come with a monthly subscription and are cleared at the end of each cycle, used or not. Permanent credits were bought outright or given as compensation, and never expire. Spending takes from the plan pot first, so the ones with a deadline go before the ones without.

When something is wrong

Every failure is the same shape: a code to branch on and a sentence to show.

{ "error": "insufficient_credits",
  "message": "This render costs 1 credit(s) and the balance is 0." }
HTTPerrorWhat to do
400unknown_kindUse WheelSwap or BodyColour.
400wheel_requiredA wheel swap needs a wheel photograph.
400invalid_base64The field is not base64. Strip whitespace, or send the whole data: URL.
400unusable_imageWe cannot read it as a photograph. message says why.
401unauthorisedThe key is missing, wrong, revoked, or its account is closed.
402insufficient_creditsWrite to us for more credits, or wait for the cycle.
402not_startedThe balance moved while the request was in flight. Nothing was charged.
404not_foundNo render with that id on this account.
409in_progressA request with the same Idempotency-Key is still being processed. Retry the same key in a moment, not a new one.
413too_largeThe photograph is bigger than this endpoint accepts.
429rate_limitedSlow down. The limits are on /api/v1/account.
503busyThis account's credits were busy with another request. Nothing was charged. Wait for the Retry-After header's seconds and send the same request again — the same Idempotency-Key is fine.
What a credit buys

A render is charged when it starts, not when it succeeds. A render that fails puts its credit back automatically — you are never charged for something you did not get. A render that delivers is charged even if you do not like the result; if a render is genuinely wrong, write to us and we will put the credit back by hand.

A whole integration

Submit, poll, save. This is the entire client — there is nothing else to it.

const KEY = process.env.WHEELSWAP_KEY;
const auth = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

async function render(carBase64, wheelBase64) {
  const started = await fetch("https://wheel-swap.com/api/v1/renders", {
    method: "POST",
    headers: auth,
    body: JSON.stringify({ kind: "WheelSwap", car: carBase64, wheel: wheelBase64 })
  });

  if (!started.ok) throw new Error((await started.json()).message);
  const { id } = await started.json();

  // Half a minute is typical. Give up long after that rather than just after it.
  for (let waited = 0; waited < 180; waited++) {
    await new Promise(r => setTimeout(r, 1000));

    const look = await fetch(`https://wheel-swap.com/api/v1/renders/${id}`, { headers: auth });
    const job = await look.json();

    if (job.result) {
      const image = await fetch(`https://wheel-swap.com${job.result}`, { headers: auth });
      return Buffer.from(await image.arrayBuffer());
    }

    if (job.status === "failed") throw new Error(job.error);
  }

  throw new Error("Still not finished after three minutes.");
}

What this API does not do yet

Listed because you will find out anyway, and it is cheaper for both of us if you find out now.