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
- 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.
- Make a spin.
You get the spin back straight away withcurl https://twurn.com/v1/spins \ -H "Authorization: Bearer tw_live_YOUR_KEY" \ -F [email protected] \ -F [email protected] \ -F name="Leather tote bag""status": "working"and itsid. - Wait about a minute, then fetch it until
statusisdone(or pass acallback_urland we'll tell you).curl https://twurn.com/v1/spins/20261110-gcn31w4bfxql \ -H "Authorization: Bearer tw_live_YOUR_KEY" - 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
- One photo from the front and one from directly behind.
- Taken from the same distance and height, in the same light. A plain background works best.
- JPEG, PNG or WebP, at least 256 pixels on each side, up to 20 MB each. The product photos already on your store usually work.
- Products with a clear front and back turn best: bags, shoes, clothing, toys, gadgets, bottles and boxes. Very shiny or see-through products are harder.
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).
| Field | Description |
|---|---|
front, back | The photos, as files. Use a multipart/form-data request. |
front_url, back_url | Or links to the photos (https, publicly reachable, no redirects). Use a JSON request. |
name | Optional. The product's name, up to 120 characters. Used as the spin's description for screen readers. |
reference | Optional. Your own id for this product (for example your product or SKU id), up to 200 characters. You can filter by it. |
callback_url | Optional. 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.
| Query | Description |
|---|---|
limit | How many to return, 1 to 100. Default 20. |
before | A spin id: return spins made before it. Use the last id of one page to get the next. |
reference | Only 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>"
}
}
| Field | Description |
|---|---|
status | working while it's being made, then done or failed. |
error | Only when failed: { "message", "refunded": true }. The spin it used is back on your balance. |
poster | The first frame, as a still image. |
frames | Every 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). |
hd | A sharper set of frames, made just after the spin finishes. status is working, done or failed. The Twurn viewer switches to HD by itself. |
embed | Ready-made code to show the spin: html (recommended) or an iframe for site builders that only accept a frame. |
warning | Sometimes 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>
| Attribute | Description |
|---|---|
spin | The spin's id. |
alt | A short description for screen readers, usually the product name. |
ratio | Width 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." } }
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Something in the request is missing or wrong. The message says what. |
| 401 | missing_key, invalid_key | No key, or the key is wrong or was deleted. |
| 402 | no_spins_left | Your balance is empty. Buy a pack in the dashboard. |
| 404 | not_found | No spin with that id on your account. |
| 409 | still_working | The spin is still being made, so it can't be deleted yet. |
| 429 | rate_limited, too_many_working | Too many requests, or too many spins being made at once. Wait and try again; Retry-After says how many seconds. |
| 500, 503 | server_error, busy | A problem on our side. Try again later. |
Limits
- 120 requests a minute per key, and up to 20 new spins a minute.
- Up to 10 spins being made at once per account.
- Photos up to 20 MB each.
Need more? Email [email protected].