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.
ეს გვერდი მხოლოდ ინგლისურადაა — ველების სახელები და კოდები ისედაც ინგლისურია. დაწერე ჩვენთან ქართულად, თუ რამე გაუგებარია.
Эта страница только на английском — названия полей и коды всё равно английские. Пишите нам по-русски, если что-то непонятно.
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.
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.
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.
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>"
}'
| Field | Required | What it is |
|---|---|---|
| kind | yes | WheelSwap to change the wheels, BodyColour to repaint
the car. |
| car | yes | The photograph of the car. Side or three-quarter on, wheels unobstructed. |
| wheel | for WheelSwap | The wheel you want fitted, on its own, face-on and filling the frame. |
| wheelMarks | no | 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. |
| wheelMark | no | Where the disk is in your wheel photograph, one x,y,r
triple. |
| option | no | For BodyColour, the colour. A name or a hex value. |
| settings | no | An object with any of rideHeight, backdrop,
finishTexture, rimColour, rimColourHex.
Omit it and nothing but the wheels changes. |
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.
{
"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.
| status | Means |
|---|---|
| queued | Accepted, not started. |
| running | Being rendered now. |
| delivered | Finished. result is set. |
| failed | Did 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. |
| errorCode | Means | What to do |
|---|---|---|
| quality_check | A 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_unavailable | The render could not be completed at that moment. | Send it again in a few minutes. |
| no_visible_wheels | No 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. |
| unspecified | Did not complete, for a reason recorded before these codes existed. | Send it again. |
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": { "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.
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." }
| HTTP | error | What to do |
|---|---|---|
| 400 | unknown_kind | Use WheelSwap or BodyColour. |
| 400 | wheel_required | A wheel swap needs a wheel photograph. |
| 400 | invalid_base64 | The field is not base64. Strip whitespace, or send the whole data: URL. |
| 400 | unusable_image | We cannot read it as a photograph. message says why. |
| 401 | unauthorised | The key is missing, wrong, revoked, or its account is closed. |
| 402 | insufficient_credits | Write to us for more credits, or wait for the cycle. |
| 402 | not_started | The balance moved while the request was in flight. Nothing was charged. |
| 404 | not_found | No render with that id on this account. |
| 409 | in_progress | A request with the same Idempotency-Key is still being processed. Retry the same key in a moment, not a new one. |
| 413 | too_large | The photograph is bigger than this endpoint accepts. |
| 429 | rate_limited | Slow down. The limits are on /api/v1/account. |
| 503 | busy | This 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. |
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.
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.");
}
Listed because you will find out anyway, and it is cheaper for both of us if you find out now.