# 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

1. Register in the [Partner Portal](https://spotstud.io/partner/register) (starts as PayAsYouGo).
2. In the portal, under **API key**, generate a key — it is shown **only once** (format `sk_live_…`).
3. On PayAsYouGo: top up your **balance** in the portal (production is only possible once the balance covers a spot).
4. Test your first call:

```bash
curl -H "Authorization: Bearer sk_live_YOUR_KEY" \
     https://spotstud.io/api/v1/me
```

Response:

```json
{
  "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 `webhookUrl` field 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 call `produce` for
  **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.

```json
[
  { "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`.

```bash
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/{id}` — status & scripts

Returns the current state. At `SCRIPTS_READY`, `scripts` contains the variants:

```json
{
  "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/{id}/produce` — pick a script & produce

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/{id}/accept` — accept (chargeable)

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/{id}/preview.mp3` — preview

**Watermarked** MP3. Available from `PREVIEW_READY`. For review before accepting.

### `GET /orders/{id}/result.mp3` — final MP3

MP3 **without watermark**. Available only after `accept` (status `COMPLETED`).

---

## 6. Error format

All errors are returned as JSON:

```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).
- **`accept` is the payment point** — only call it after reviewing the preview. Idempotency
  protects against accidental double billing.
- **Use `clientRef`** to 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; `GET` is 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

```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`).

```java
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`).

```csharp
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](https://spotstud.io/portal).
