Twurn API

Send a product's front and back photos, get a 360° spin shoppers can drag, about a minute later. Show it anywhere with one tag.

Quick start

  1. Get a key. Sign in to your Twurn dashboard, scroll to API and press Create key. Each spin uses one spin from your balance; new accounts get free spins to try it.
  2. Make a spin.
    curl https://twurn.com/v1/spins \
      -H "Authorization: Bearer tw_live_YOUR_KEY" \
      -F [email protected] \
      -F [email protected] \
      -F name="Leather tote bag"
    You get the spin back straight away with "status": "working" and its id.
  3. Wait about a minute, then fetch it until status is done (or pass a callback_url and we'll tell you).
    curl https://twurn.com/v1/spins/20261110-gcn31w4bfxql \
      -H "Authorization: Bearer tw_live_YOUR_KEY"
  4. Show it. A finished spin includes ready-made code in embed.html. Put it on your product page:
    <script src="https://twurn.com/embed/twurn-spin.js" async></script>
    <twurn-spin spin="20261110-gcn31w4bfxql" alt="Leather tote bag"></twurn-spin>

Authentication

Send your key in the Authorization header of every request:

Authorization: Bearer tw_live_YOUR_KEY

Keys start with tw_live_. Keep them on your server, never in a web page or app that people download: anyone with your key can use your spins. If a key leaks, delete it in the dashboard and make a new one. You can have up to 10 keys.

All endpoints are under https://twurn.com/v1, take and return JSON (except photo uploads), and use https only.

Photos that work

Make a spin

POST/v1/spins

Starts a spin and takes one spin from your balance. If the spin fails, the spin is given back automatically. Send the photos as files (multipart form) or as links (JSON).

FieldDescription
front, backThe photos, as files. Use a multipart/form-data request.
front_url, back_urlOr links to the photos (https, publicly reachable, no redirects). Use a JSON request.
nameOptional. The product's name, up to 120 characters. Used as the spin's description for screen readers.
referenceOptional. Your own id for this product (for example your product or SKU id), up to 200 characters. You can filter by it.
callback_urlOptional. An https address we POST to when the spin is done or fails. See notifications.
# With links instead of files
curl https://twurn.com/v1/spins \
  -H "Authorization: Bearer tw_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"front_url": "https://example.com/tote-front.jpg",
       "back_url": "https://example.com/tote-back.jpg",
       "name": "Leather tote bag", "reference": "sku-1042"}'

Returns 202 Accepted with the spin, status: "working". The Twurn-Spins-Left response header has your balance after this spin.

// Node.js 18+
const form = new FormData();
form.append('front', new Blob([fs.readFileSync('front.jpg')]), 'front.jpg');
form.append('back', new Blob([fs.readFileSync('back.jpg')]), 'back.jpg');
form.append('name', 'Leather tote bag');
const res = await fetch('https://twurn.com/v1/spins', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.TWURN_API_KEY}` },
  body: form,
});
const spin = await res.json();
<?php // PHP with cURL
$ch = curl_init('https://twurn.com/v1/spins');
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('TWURN_API_KEY')],
  CURLOPT_POSTFIELDS => [
    'front' => new CURLFile('front.jpg'),
    'back'  => new CURLFile('back.jpg'),
    'name'  => 'Leather tote bag',
  ],
]);
$spin = json_decode(curl_exec($ch), true);

Get a spin

GET/v1/spins/id

Returns the spin. While it's being made, check every 5 to 10 seconds; most spins finish in about a minute.

List spins

GET/v1/spins

Your spins, newest first.

QueryDescription
limitHow many to return, 1 to 100. Default 20.
beforeA spin id: return spins made before it. Use the last id of one page to get the next.
referenceOnly spins with this reference.
{ "object": "list", "data": [ /* spins */ ], "has_more": false }

Delete a spin

DELETE/v1/spins/id

Deletes the spin and its images. It stops showing everywhere it's embedded. The spin it used isn't given back. A spin that's still being made can't be deleted until it finishes.

{ "id": "20261110-gcn31w4bfxql", "object": "spin", "deleted": true }

Account

GET/v1/account

Your store name and spins left. Handy for checking that a key works.

{ "object": "account", "name": "Acme Bags", "website": "https://acmebags.com", "spins_left": 48 }

The spin object

{
  "id": "20261110-gcn31w4bfxql",
  "object": "spin",
  "status": "done",
  "name": "Leather tote bag",
  "reference": "sku-1042",
  "created_at": "2026-11-10T16:47:32.604Z",
  "finished_at": "2026-11-10T16:48:41.120Z",
  "width": 720, "height": 960,
  "turn": 1,
  "poster": "https://…/frames/f001.webp",
  "frames": ["https://…/frames/f001.webp", "…"],
  "hd": { "status": "done", "width": 1440, "height": 1920, "frames": ["…"] },
  "embed": {
    "html": "<script src=…></script>\n<twurn-spin spin=…></twurn-spin>",
    "iframe_url": "https://twurn.com/embed/20261110-gcn31w4bfxql",
    "iframe_html": "<iframe src=…></iframe>"
  }
}
FieldDescription
statusworking while it's being made, then done or failed.
errorOnly when failed: { "message", "refunded": true }. The spin it used is back on your balance.
posterThe first frame, as a still image.
framesEvery frame in order, if you want to build your own viewer. Playing them forward turns the product in the direction given by turn (1 or -1).
hdA sharper set of frames, made just after the spin finishes. status is working, done or failed. The Twurn viewer switches to HD by itself.
embedReady-made code to show the spin: html (recommended) or an iframe for site builders that only accept a frame.
warningSometimes set on a finished spin that may not look right, with advice for a better retry.

Fields only in finished spins (width to embed) are left out while the spin is working.

Showing a spin

Add the viewer script once per page, then a <twurn-spin> tag wherever a spin should go. It fills the width it's in; shoppers drag to turn it, and it loads only when scrolled into view.

<script src="https://twurn.com/embed/twurn-spin.js" async></script>
<twurn-spin spin="SPIN_ID" alt="Leather tote bag" ratio="0.75" style="max-width:600px"></twurn-spin>
AttributeDescription
spinThe spin's id.
altA short description for screen readers, usually the product name.
ratioWidth divided by height, so the page doesn't jump while it loads. embed.html fills this in.
autoplay="off"Don't give the product a short turn when it first appears.

If a spin can't be loaded (for example it was deleted), the tag hides itself, so your page never shows a broken box.

Spin-ready notifications

Instead of checking, pass a callback_url when you make a spin. When it finishes or fails, we send a POST there with this JSON body:

{ "type": "spin.done", "created_at": "2026-11-10T16:48:41.500Z", "spin": { /* the spin object */ } }

type is spin.done or spin.failed. Answer with any 2xx status within 10 seconds. If you don't, we try again after 1, 5 and 30 minutes.

Checking it came from Twurn

Each notification has a Twurn-Signature header like t=1762793321,v1=5f8e…. v1 is an HMAC-SHA256 of t, a full stop, and the raw request body, made with your webhook signing secret (in the dashboard, under API). Compare it with your own, and reject old timestamps.

// Node.js (Express): use the raw body, not parsed JSON
app.post('/twurn-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const [t, v1] = req.get('Twurn-Signature').split(',').map((p) => p.split('=')[1]);
  const mine = crypto.createHmac('sha256', process.env.TWURN_WEBHOOK_SECRET)
    .update(`${t}.${req.body}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
  if (!fresh || !crypto.timingSafeEqual(Buffer.from(mine), Buffer.from(v1))) return res.sendStatus(400);
  const { type, spin } = JSON.parse(req.body);
  // … save spin.id and spin.embed.html with your product
  res.sendStatus(200);
});
<?php // PHP
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_TWURN_SIGNATURE'] ?? ''), $sig);
$mine = hash_hmac('sha256', $sig['t'] . '.' . $body, getenv('TWURN_WEBHOOK_SECRET'));
if (abs(time() - (int) $sig['t']) > 300 || !hash_equals($mine, $sig['v1'] ?? '')) { http_response_code(400); exit; }
$event = json_decode($body, true);

Errors

Errors return a non-2xx status and a JSON body:

{ "error": { "code": "no_spins_left", "message": "This account has no spins left. Buy a pack in your Twurn dashboard." } }
StatusCodeMeaning
400invalid_requestSomething in the request is missing or wrong. The message says what.
401missing_key, invalid_keyNo key, or the key is wrong or was deleted.
402no_spins_leftYour balance is empty. Buy a pack in the dashboard.
404not_foundNo spin with that id on your account.
409still_workingThe spin is still being made, so it can't be deleted yet.
429rate_limited, too_many_workingToo many requests, or too many spins being made at once. Wait and try again; Retry-After says how many seconds.
500, 503server_error, busyA problem on our side. Try again later.

Limits

Need more? Email [email protected].