SpotStud.io Partner API — Developer Guide
Create broadcast-ready radio spots programmatically: place an order, pick from AI-written scripts, produce, review the preview, accept, and download the finished MP3.
- Base URL:
https://spotstud.io/api/v1 - Version:
v1 - Auth: API key as
Authorization: Bearer <key> - Format: JSON (UTF-8); audio files as
audio/mpeg
1. Quickstart
- Register in the Partner Portal (starts as PayAsYouGo).
- In the portal, under API key, generate a key — it is shown only once (format
sk_live_…). - On PayAsYouGo: top up your balance in the portal (production is only possible once the balance covers a spot).
- Test your first call:
curl -H "Authorization: Bearer sk_live_YOUR_KEY" \
https://spotstud.io/api/v1/me
Response:
{
"partner": "Your Company Ltd",
"quota": {
"hasContract": true,
"billingModel": "PAYG",
"spotsUsed": 3,
"spotsLimit": null,
"spotsRemaining": null,
"attemptsPerSpot": 5,
"overageAllowed": false,
"canStartSpot": true,
"spotNetCents": 1900,
"spotGrossCents": 2261,
"vatMode": "STANDARD",
"balanceCents": 8500
}
}
2. Authentication
Every request needs the API key in the header:
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- Keys are stored hashed only on the server — if lost, rotate it in the portal (the old key becomes invalid immediately).
- Without a key, or with an invalid one, the API responds with
401 Unauthorized. - Treat the key like a password: use it server-side only, never ship it to a browser/client.
3. The spot lifecycle
An order moves through fixed states. You trigger the transitions with POST requests and
track progress by polling (GET /orders/{id}).
POST /orders
│ status: NEW
▼ (AI writes script variants)
status: SCRIPTS_READY ──► you pick a script
│
POST /orders/{id}/produce
│ status: PRODUCING
▼ (audio is produced)
status: PREVIEW_READY ──► review the preview (watermarked)
│
POST /orders/{id}/accept ◄── billing happens here
│ status: COMPLETED
▼
GET /orders/{id}/result.mp3 (final MP3, no watermark)
Status values: NEW, SCRIPTS_READY, PRODUCING, PREVIEW_READY, COMPLETED, ERROR.
Billing only happens at
accept. Scripts, production and the preview are free; a spot is only charged (or the balance debited) when you accept the preview.
Webhooks: the
webhookUrlfield on order creation is stored, but outbound webhook delivery is currently not active. Please use polling (see section 7).
4. Attempts, quotas & billing
- Attempts per spot (
attemptsPerSpot, usually 5): how often you may callproducefor one order (e.g. choose another script, tweak the text) before accepting. Once attempts are used up, create a new order. - PayAsYouGo (PAYG): no monthly limit. Each accept costs €19 net (plus VAT depending on
country). The gross amount is debited from your balance; if it isn't enough →
402 insufficient_funds. Top up the balance via PayPal in the portal. - Monthly plans (PRO/BUSINESS): a fixed number of spots per month. Accepted spots count
against the monthly limit; once exhausted →
402 quota_exceeded. Manage plans in the portal. - Invoices: every accept or subscription payment produces an invoice with a sequential number and correct VAT treatment (domestic 19% / EU reverse charge / non-EU). Download as PDF in the portal.
GET /me returns the current state at any time (canStartSpot, balanceCents, spotsRemaining, …).
5. Endpoint reference
GET /me — account & quota
Partner name + quota/balance status. Amounts in cents. On monthly plans the PAYG fields
(spotNetCents, spotGrossCents, vatMode, balanceCents) are null, and vice versa.
GET /languages — allowed spot languages
Returns the languages a spot can be produced in (all ElevenLabs-supported languages). Use a
name or code value for the language field when creating an order.
[
{ "name": "German", "code": "de" },
{ "name": "English", "code": "en" },
{ "name": "Spanish", "code": "es" }
]
GET /orders — list your orders
Returns the partner's own orders, newest first. Optional query params: clientRef (filter to
your own reference, e.g. one of your customers) and limit (1–200, default 50). Compact form
(no scripts): orderId, status, clientRef, companyName, language, lengthSeconds,
created, previewUrl, resultUrl.
curl -H "Authorization: Bearer sk_live_YOUR_KEY" \
"https://spotstud.io/api/v1/orders?clientRef=customer-123&limit=20"
POST /orders — create an order
Body:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
companyName |
string | ✓ | — | Advertised company / brand |
productInfo |
string | ✓ | — | What is advertised (product/offer) |
targetAudience |
string | — | Target audience (free text) | |
tone |
string | — | Free text. Suggested: friendly & warm, funny & cheeky, serious & trustworthy, emotional & moving, energetic & loud — or your own. |
|
callToAction |
string | — | Call to action (free text) | |
language |
string | German |
Any ElevenLabs-supported language, by name or ISO code (case-insensitive), e.g. German, English, Spanish, es. Full list: GET /api/v1/languages. Unknown value → 400 invalid_request. |
|
lengthSeconds |
int | 30 |
Allowed: 20, 30, 45, 60. Any other value → 400 invalid_request. (Price is flat per spot regardless of length.) |
|
wantsSfx |
bool | true |
Include sound effects/music | |
voicePreference |
string | — | Free text. E.g. one female voice, two voices (dialogue), deep male narrator. |
|
extraWishes |
string | — | Free text: a slogan that must appear, no-gos, style references … | |
clientRef |
string | — | Your own reference (echoed in responses) | |
webhookUrl |
string | — | Reserved (no active delivery, see above) |
Response 202 Accepted — order with status: NEW. Then poll until SCRIPTS_READY.
`GET /orders/
Returns the current state. At SCRIPTS_READY, scripts contains the variants:
{
"orderId": "4f3c…",
"status": "SCRIPTS_READY",
"clientRef": "campaign-42",
"scripts": [
{ "id": "a1…", "variant": 1, "title": "…", "concept": "…", "script": "Voice-over text …" }
],
"previewUrl": null,
"resultUrl": null,
"error": null
}
At PREVIEW_READY/COMPLETED previewUrl is set; at COMPLETED also resultUrl.
On ERROR, the cause is in error.
`POST /orders/
Body:
| Field | Type | Required | Description |
|---|---|---|---|
scriptId |
GUID | ✓ | ID of a variant from scripts |
editedText |
string | Optionally adjusted voice-over text |
Response 202 with status: PRODUCING. Then poll until PREVIEW_READY. Allowed only in state
SCRIPTS_READY (or ERROR to retry), as long as attempts remain.
`POST /orders/
Charges the spot: on PAYG the balance is debited by the gross price and an invoice is created;
on monthly plans one spot counts against the quota. Idempotent — an already accepted order
simply returns the result. Response 200 with status: COMPLETED and resultUrl.
`GET /orders/
Watermarked MP3. Available from PREVIEW_READY. For review before accepting.
`GET /orders/
MP3 without watermark. Available only after accept (status COMPLETED).
6. Error format
All errors are returned as JSON:
{ "error": { "code": "insufficient_funds", "message": "Balance does not cover a spot. Please top up." } }
| HTTP | code |
Meaning |
|---|---|---|
| 400 | invalid_request |
Missing required field or invalid script |
| 401 | — | Key missing/invalid |
| 402 | insufficient_funds |
PAYG balance does not cover a spot |
| 402 | quota_exceeded |
Monthly quota exhausted |
| 402 | no_contract |
No active contract |
| 404 | — | Order not found (or not owned by this key) |
| 409 | invalid_state |
Action not allowed in the current status |
| 409 | attempts_exhausted |
Attempts for this spot used up → create a new order |
| 409 | spot_charged |
A spot was already charged for this order |
7. Best practices
- Polling: query
GET /orders/{id}every 2–5 seconds; production usually takes some tens of seconds depending on length. Use a timeout (e.g. 5 minutes). acceptis the payment point — only call it after reviewing the preview. Idempotency protects against accidental double billing.- Use
clientRefto map orders to your own records. - Check balance/quota before creating an order via
GET /me(canStartSpot). - Retries: on
5xx/network errors, retry with backoff;GETis safe to repeat.
8. Full examples (end-to-end)
The flow is the same everywhere: create order → wait for SCRIPTS_READY → pick a script &
produce → wait for PREVIEW_READY → accept → download result.mp3.
Python
import time, requests
BASE = "https://spotstud.io/api/v1"
KEY = "sk_live_YOUR_KEY"
H = {"Authorization": f"Bearer {KEY}"}
def wait_for(order_id, target, timeout=300):
deadline = time.time() + timeout
while time.time() < deadline:
r = requests.get(f"{BASE}/orders/{order_id}", headers=H)
r.raise_for_status()
data = r.json()
if data["status"] == target:
return data
if data["status"] == "ERROR":
raise RuntimeError(data.get("error"))
time.sleep(3)
raise TimeoutError(f"Status {target} not reached")
# 1) Create the order
order = requests.post(f"{BASE}/orders", headers=H, json={
"companyName": "Sample Ltd",
"productInfo": "Spring sale: 20% off all bikes",
"targetAudience": "commuters 25–45",
"tone": "fresh, motivating",
"callToAction": "Come by the shop today",
"language": "English",
"lengthSeconds": 30,
"clientRef": "campaign-42",
}).json()
oid = order["orderId"]
# 2) Wait for scripts and pick the first
ready = wait_for(oid, "SCRIPTS_READY")
script = ready["scripts"][0]
# 3) Produce
requests.post(f"{BASE}/orders/{oid}/produce", headers=H,
json={"scriptId": script["id"]}).raise_for_status()
# 4) Wait for the preview, review it (optionally download preview.mp3)
wait_for(oid, "PREVIEW_READY")
with open("preview.mp3", "wb") as f:
f.write(requests.get(f"{BASE}/orders/{oid}/preview.mp3", headers=H).content)
# 5) Accept (chargeable) and download the final MP3
done = requests.post(f"{BASE}/orders/{oid}/accept", headers=H).json()
with open("spot.mp3", "wb") as f:
f.write(requests.get(f"{BASE}/orders/{oid}/result.mp3", headers=H).content)
print("Done:", done["status"])
Java
Requires Java 11+ (java.net.http) and Jackson (com.fasterxml.jackson.core:jackson-databind).
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import com.fasterxml.jackson.databind.*;
public class SpotStudioClient {
static final String BASE = "https://spotstud.io/api/v1";
static final String KEY = "sk_live_YOUR_KEY";
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static HttpRequest.Builder req(String path) {
return HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + KEY)
.header("Content-Type", "application/json");
}
static JsonNode send(HttpRequest r) throws Exception {
HttpResponse<String> res = HTTP.send(r, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException("HTTP " + res.statusCode() + ": " + res.body());
return JSON.readTree(res.body());
}
static JsonNode waitFor(String id, String target) throws Exception {
long deadline = System.currentTimeMillis() + 300_000;
while (System.currentTimeMillis() < deadline) {
JsonNode d = send(req("/orders/" + id).GET().build());
String s = d.get("status").asText();
if (s.equals(target)) return d;
if (s.equals("ERROR")) throw new RuntimeException("Order error: " + d.get("error"));
Thread.sleep(3000);
}
throw new RuntimeException("Status " + target + " not reached");
}
public static void main(String[] args) throws Exception {
String body = """
{"companyName":"Sample Ltd",
"productInfo":"Spring sale: 20% off all bikes",
"language":"English","lengthSeconds":30,"clientRef":"campaign-42"}""";
JsonNode order = send(req("/orders").POST(HttpRequest.BodyPublishers.ofString(body)).build());
String oid = order.get("orderId").asText();
JsonNode ready = waitFor(oid, "SCRIPTS_READY");
String scriptId = ready.get("scripts").get(0).get("id").asText();
send(req("/orders/" + oid + "/produce")
.POST(HttpRequest.BodyPublishers.ofString("{\"scriptId\":\"" + scriptId + "\"}")).build());
waitFor(oid, "PREVIEW_READY");
send(req("/orders/" + oid + "/accept").POST(HttpRequest.BodyPublishers.noBody()).build());
byte[] mp3 = HTTP.send(req("/orders/" + oid + "/result.mp3").GET().build(),
HttpResponse.BodyHandlers.ofByteArray()).body();
Files.write(Path.of("spot.mp3"), mp3);
System.out.println("Done, " + mp3.length + " bytes");
}
}
C#
.NET 6+ (HttpClient + System.Text.Json).
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
const string Base = "https://spotstud.io/api/v1";
const string Key = "sk_live_YOUR_KEY";
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Key);
async Task<JsonElement> Get(string path) =>
(await http.GetFromJsonAsync<JsonElement>($"{Base}{path}"));
async Task<JsonElement> WaitFor(string id, string target, int timeoutSec = 300)
{
var deadline = DateTime.UtcNow.AddSeconds(timeoutSec);
while (DateTime.UtcNow < deadline)
{
var d = await Get($"/orders/{id}");
var status = d.GetProperty("status").GetString();
if (status == target) return d;
if (status == "ERROR") throw new Exception("Order error: " + d.GetProperty("error"));
await Task.Delay(3000);
}
throw new TimeoutException($"Status {target} not reached");
}
// 1) Create
var create = await http.PostAsJsonAsync($"{Base}/orders", new
{
companyName = "Sample Ltd",
productInfo = "Spring sale: 20% off all bikes",
targetAudience = "commuters 25–45",
language = "English",
lengthSeconds = 30,
clientRef = "campaign-42"
});
create.EnsureSuccessStatusCode();
var order = await create.Content.ReadFromJsonAsync<JsonElement>();
var oid = order.GetProperty("orderId").GetString()!;
// 2) Wait for scripts, pick the first
var ready = await WaitFor(oid, "SCRIPTS_READY");
var scriptId = ready.GetProperty("scripts")[0].GetProperty("id").GetString();
// 3) Produce
(await http.PostAsJsonAsync($"{Base}/orders/{oid}/produce", new { scriptId }))
.EnsureSuccessStatusCode();
// 4) Wait for the preview
await WaitFor(oid, "PREVIEW_READY");
// 5) Accept + download the final MP3
(await http.PostAsync($"{Base}/orders/{oid}/accept", null)).EnsureSuccessStatusCode();
var mp3 = await http.GetByteArrayAsync($"{Base}/orders/{oid}/result.mp3");
await File.WriteAllBytesAsync("spot.mp3", mp3);
Console.WriteLine($"Done, {mp3.Length} bytes");
9. Support
Questions about integration, quotas or invoices: info@digitalanna.de. Manage keys, top up your balance and download invoices in the Partner Portal.