RiftVoiceLab · Öffentlicher Zugang
Ein Schlüssel, ein Regal.
Text hinein, MP3 heraus — mit genau den Stimmen, die deinem Schlüssel zugeteilt sind. Ein Endpunkt zum Auflisten, einer zum Sprechen. Mehr braucht es nicht.
https://…Authorization: Bearer rvl_…JSON hinein · audio/mpeg herausSchnellstart
Drei Schritte. Der erste passiert nicht hier, sondern in der Werkbank.
- Schlüssel holen. Werkbank → Reiter API → Name vergeben, Stimmen anhaken, Schlüssel ausstellen. Der Schlüssel steht genau einmal auf dem Bildschirm. Danach liegt bei uns nur noch sein Abdruck — verloren heißt neu ausstellen.
- Stimmen nachsehen.
GET /api/v1/voiceszeigt dein Regal und die Kennungen darin. - Sprechen lassen.
POST /api/v1/speechmittextundvoice.
# 1) Regal ansehen
curl -s https://HOST/api/v1/voices \
-H "Authorization: Bearer $RIFT_KEY"
# 2) Sprechen lassen — die MP3 landet direkt in der Datei
curl -s https://HOST/api/v1/speech \
-H "Authorization: Bearer $RIFT_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"Guten Abend. Der Ton steht.","voice":"VOICE_ID"}' \
-o ansage.mp3
const KEY = process.env.RIFT_KEY; // niemals im Frontend
const BASE = "https://HOST";
// 1) Regal ansehen
const { data: voices } = await fetch(`${BASE}/api/v1/voices`, {
headers: { authorization: `Bearer ${KEY}` },
}).then((r) => r.json());
// 2) Sprechen lassen
const res = await fetch(`${BASE}/api/v1/speech`, {
method: "POST",
headers: {
authorization: `Bearer ${KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
text: "Guten Abend. Der Ton steht.",
voice: voices[0].id,
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
const mp3 = Buffer.from(await res.arrayBuffer());
console.log("Rest heute:", res.headers.get("x-rift-quota-remaining"));
import os, requests
KEY = os.environ["RIFT_KEY"] # niemals im Repository
BASE = "https://HOST"
head = {"Authorization": f"Bearer {KEY}"}
# 1) Regal ansehen
voices = requests.get(f"{BASE}/api/v1/voices", headers=head).json()["data"]
# 2) Sprechen lassen
res = requests.post(
f"{BASE}/api/v1/speech",
headers=head,
json={"text": "Guten Abend. Der Ton steht.", "voice": voices[0]["id"]},
timeout=90,
)
res.raise_for_status()
with open("ansage.mp3", "wb") as f:
f.write(res.content)
print("Rest heute:", res.headers["X-Rift-Quota-Remaining"])
<?php
$key = getenv('RIFT_KEY');
$base = 'https://HOST';
$ch = curl_init("$base/api/v1/speech");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $key",
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'text' => 'Guten Abend. Der Ton steht.',
'voice' => 'VOICE_ID',
]),
]);
$mp3 = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200) { throw new RuntimeException($mp3); }
file_put_contents('ansage.mp3', $mp3);
Authentifizierung
Jede Anfrage außer /health braucht deinen Schlüssel. Zwei gleichwertige Wege:
Authorization: Bearer rvl_7fQ2x9aBc…
X-API-Key: rvl_7fQ2x9aBc…
Ein Schlüssel beginnt immer mit rvl_. Was danach kommt, sind 32 Zufallsbytes —
geraten wird da nichts.
Beim Ausstellen. Gespeichert wird nur der SHA-256 des Schlüssels; niemand — auch die Werkbank nicht — kann ihn später wieder anzeigen.
Stilllegen hält ihn an und lässt ihn stehen, Widerrufen löscht ihn, Neu ausstellen tauscht das Geheimnis und behält alle Einstellungen.
Eine Sitzung der Web-App öffnet die API nicht, und ein API-Schlüssel öffnet die Web-App nicht. Die beiden Wege teilen sich nur Engine und Stimmen.
Stimmen & Zuteilung
Ein Schlüssel bekommt in der Werkbank entweder alle Stimmen oder eine ausdrückliche Liste. Diese Zuteilung ist die ganze Berechtigungslogik — es gibt keine zweite Ebene.
- Alle Stimmen: auch die, die später dazukommen. Praktisch für eigene Dienste.
- Auswahl: genau die angehakten. Neue Stimmen sind für diesen Schlüssel nicht dabei.
- Was nicht zugeteilt ist, existiert nicht. Eine fremde Kennung beantwortet
/speechmit404 voice_not_found— derselben Antwort wie für eine Kennung, die es nirgends gibt. Aus der Antwort lässt sich das fremde Regal nicht abtasten. - Die interne Referenz bleibt drinnen. Nach draußen geht nur die kurze Kennung aus
/voices, nie die Kennung, mit der die Engine arbeitet. - Über der Zuteilung steht der Riegel. Jede Stimme trägt in der Werkbank den
Schalter öffentlich verwendbar. Steht er auf aus, ist die Stimme für den offenen
Zugang nicht vorhanden — auch dann nicht, wenn ein Schlüssel sie ausdrücklich zugeteilt
bekommen hat. Sie verschwindet aus
/voicesund/speechantwortet404 voice_not_found.
Modelle
Zwei Wege, dieselbe Stimme. Welchen du nimmst, entscheidet das Feld model
bei /speech.
| Modell | Was es tut | Zeichen je Zeichen |
|---|---|---|
RiftVoiceV1 |
Spricht den Text so, wie er dasteht. Das ist der Standard und der Weg, den es immer gab. | 1 |
RiftVoiceV2 |
Liest den Text vorher mit, setzt Betonung, Pausen und Stimmung selbst und spricht ihn dann — realistischer als jedes verfügbare Modell. Du schickst denselben Text wie bei V1 — um den Rest kümmert sich der Dienst. | 2 |
Rift 2.1 |
Überragende Tonqualität. Dieselbe Regie wie bei V2, aber sparsam
dosiert: höchstens eine Marke je zwei Sätze, und Pausen direkt neben einem
Satzzeichen werden wieder entfernt — dort macht die Engine die Pause ohnehin,
und beides zusammen klingt wie ein Versprecher. Dazu die Ausgabe mit
192 kbit/s statt 128. Eine Aufnahme, die zu kurz für den Text ist,
wird nicht ausgeliefert, sondern neu genommen. Braucht keinen zweiten
Durchgang am Text und läuft damit nicht länger als V2. Kurzformen:
2.1, rift2.1. |
2,5 |
"v1" und "v2" sind als Kurzform ebenfalls erlaubt. Ohne
model gilt RiftVoiceV1 — bestehende Aufrufe ändern sich also
nicht. Ein Name, den es nicht gibt, ergibt 404 model_not_found; still
ersetzt wird nie.
RiftVoiceV2 zählt jedes Zeichen doppelt, bei Rift 2.1 zweieinhalbfach gegen das
Tageskontingent. 100 Zeichen Text sind 200 Zeichen Kontingent. Was wirklich gebucht wurde,
steht in jeder Antwort: im Kopf X-Rift-Billed-Chars und im Feld
billedCharacters. Bleibt die Betonung einmal aus, wird der Aufschlag
zurückgebucht und der Lauf kostet den einfachen Satz.
Grenzen & Kontingent
| Grenze | Wert | Was passiert beim Überschreiten |
|---|---|---|
| Zeichen je Anfrage | 1000 | 400 text_too_long |
| Zeichen je Tag | pro Schlüssel gesetzt | 429 quota_exhausted |
| Anfragen je Minute | 60 | 429 rate_limited mit Retry-After |
| gleichzeitig | 2 | 409 concurrent_limit |
| Größe des Rumpfs | 64 kB | 413 payload_too_large |
Abgerechnet wird die Länge des bereinigten Textes, nicht die der MP3 — bei
RiftVoiceV2 mal zwei, Rift 2.1 mal zweieinhalb, siehe Modelle. Der Tageszähler springt
um Mitternacht in Berlin zurück; wann genau, steht in jeder Antwort unter
quota.resetsAt beziehungsweise im Kopf X-Rift-Quota-Reset.
502 geht also nicht von deinem Kontingent ab.
/api/v1/voices
Alle Stimmen, die dieser Schlüssel benutzen darf. Ohne Parameter, ohne Seitenzahlen.
Antwort 200
{
"object": "list",
"count": 2,
"data": [
{ "id": "8kq2m1x9", "name": "Erzähler Nord", "tone": "warm, ruhig", "language": "DE" },
{ "id": "p4v7t0zc", "name": "Ansage Klar", "tone": "sachlich", "language": "DE" }
]
}
| Feld | Typ | Bedeutung |
|---|---|---|
id | string | Das, was bei /speech ins Feld voice gehört. |
name | string | Anzeigename aus der Werkbank. |
tone | string | Charakterbeschreibung, kann leer sein. |
language | string | Sprachkürzel, kann leer sein. |
/api/v1/voices/{voiceId}
Eine einzelne Stimme nachschlagen — nützlich, um eine gespeicherte Kennung zu prüfen, bevor ein Batch losläuft.
Antwort 200
{ "id": "8kq2m1x9", "name": "Erzähler Nord", "tone": "warm, ruhig", "language": "DE" }
Nicht zugeteilt oder nicht vorhanden ergibt beides 404 voice_not_found.
/api/v1/models
Welche Modelle es gibt und was sie kosten. Ohne Parameter.
Antwort 200
{
"object": "list",
"count": 3,
"data": [
{
"id": "RiftVoiceV1",
"alias": "v1",
"name": "RiftVoiceV1",
"characterCost": 1,
"description": "Spricht den Text so, wie er dasteht. Ein Zeichen zählt als ein Zeichen.",
"available": true
},
{
"id": "RiftVoiceV2",
"alias": "v2",
"name": "RiftVoiceV2",
"characterCost": 2,
"description": "Liest den Text vorher mit, setzt Betonung, Pausen und Stimmung selbst und spricht ihn dann — realistischer als jedes verfügbare Modell. Ein Zeichen zählt als zwei Zeichen.",
"available": true
},
{
"id": "Rift 2.1",
"alias": "r21",
"name": "Rift 2.1",
"characterCost": 2.5,
"description": "Setzt Betonung sparsam statt überall, nimmt doppelte Pausen wieder heraus und gibt mit 192 kbit/s aus statt 128. Eine zu kurz geratene Aufnahme lässt es nicht durch, sondern nimmt sie neu. Ein Zeichen zählt als zweieinhalb Zeichen.",
"available": true
}
]
}
| Feld | Typ | Bedeutung |
|---|---|---|
id | string | Das, was bei /speech ins Feld model gehört. |
alias | string | Kurzform, die dort ebenfalls gilt. |
characterCost | integer | Wie viele Zeichen ein Zeichen vom Kontingent nimmt. |
available | boolean | Steht hier false, ist das Modell gerade nicht abrufbar. |
/api/v1/speech
Der eigentliche Endpunkt. Erwartet JSON, liefert standardmäßig rohe MP3-Bytes.
Rumpf
| Feld | Typ | Pflicht | Bedeutung |
|---|---|---|---|
text | string | ja | Was gesprochen werden soll. Höchstens 1000 Zeichen nach dem Bereinigen. |
voice | string | ja | Kennung aus /voices. voice_id geht auch. |
model | string | nein | "RiftVoiceV1" (Standard), "RiftVoiceV2" oder "Rift 2.1"; "v1", "v2" und "2.1" gehen auch. Siehe Modelle. |
format | string | nein | Derzeit nur "mp3". |
encoding | string | nein | "binary" (Standard) liefert audio/mpeg, "base64" liefert JSON. |
Anfrage
{
"text": "Guten Abend. Der Ton steht.",
"voice": "8kq2m1x9",
"model": "RiftVoiceV2"
}
Antwort 200 · audio/mpeg
Der Rumpf ist die MP3. Alles Zählbare steht in den Köpfen:
X-Rift-Request-Id: rk9f2c1a4b7d0e3
X-Rift-Voice: 8kq2m1x9
X-Rift-Model: RiftVoiceV2
X-Rift-Chars: 27
X-Rift-Billed-Chars: 54
X-Rift-Bytes: 41984
X-Rift-Ms: 1840
X-Rift-Quota-Remaining: 4973
X-Rift-Quota-Reset: 2026-09-04T00:00:00.000Z
Antwort mit "encoding": "base64"
{
"object": "speech",
"requestId": "rk9f2c1a4b7d0e3",
"model": "RiftVoiceV2",
"format": "mp3",
"encoding": "base64",
"audio": "SUQzBAAAAAAA…",
"bytes": 41984,
"characters": 27,
"billedCharacters": 54,
"durationMs": 1840,
"voice": { "id": "8kq2m1x9", "name": "Erzähler Nord", "tone": "warm, ruhig", "language": "DE" },
"quota": {
"unlimited": false,
"limit": 5000,
"used": 27,
"remaining": 4973,
"day": "2026-09-03",
"resetsAt": "2026-09-04T00:00:00.000Z"
}
}
Base64 macht die Antwort rund ein Drittel größer. Für Warteschlangen und Webhooks bequem, für Durchsatz nicht.
/api/v1/me
Zustand des Schlüssels: Kontingent, Grenzen, Umfang des Regals. Gut für ein Startprotokoll oder ein Dashboard.
Antwort 200
{
"key": {
"id": "km0q7c1f2a9b4d",
"name": "Hörbuch-Pipeline",
"hint": "rvl_7fQ2x9aB…4kQz",
"active": true,
"createdAt": "2026-09-01T09:12:44.201Z",
"lastUsedAt": "2026-09-03T16:02:11.884Z",
"expiresAt": null
},
"quota": { "unlimited": false, "limit": 5000, "used": 27, "remaining": 4973,
"day": "2026-09-03", "resetsAt": "2026-09-04T00:00:00.000Z" },
"limits": { "maxTextLength": 1000, "requestsPerMinute": 60, "concurrentRequests": 2 },
"voices": { "scope": "selected", "count": 2 },
"totals": { "calls": 143, "characters": 38122 }
}
hint ist der Wiedererkennungswert des Schlüssels, nicht der Schlüssel.
/api/v1/health
ohne SchlüsselSagt, ob der Dienst antwortet. Sonst nichts — kein Zustand, keine Zahlen.
{ "status": "ok", "time": "2026-09-03T16:02:11.884Z", "version": "1.0.0" }
Fehler
Immer JSON, immer dieselbe Form. Prüfe error.code, nicht den Wortlaut —
Texte dürfen sich ändern, Codes nicht.
{
"error": {
"type": "rate_limit_error",
"code": "quota_exhausted",
"message": "Das Tageskontingent dieses Schluessels ist aufgebraucht.",
"quota": { "limit": 5000, "used": 5000, "remaining": 0, "resetsAt": "2026-09-04T00:00:00.000Z" },
"requestId": "rk9f2c1a4b7d0e3",
"docs": "/api"
}
}
| HTTP | code | Bedeutung & nächster Schritt |
|---|---|---|
| 400 | text_required | text fehlt oder ist nach dem Bereinigen leer. |
| 400 | text_too_long | Text kürzen oder in Abschnitte teilen. |
| 400 | voice_required | voice fehlt. |
| 400 | invalid_request | Rumpf ist kein gültiges JSON-Objekt, oder ein Wert kennt diese Option nicht. |
| 401 | missing_key | Kopf fehlt ganz. |
| 401 | invalid_key | Unbekannt oder widerrufen. Nicht wiederholen — neu ausstellen. |
| 403 | key_disabled | In der Werkbank stillgelegt. |
| 403 | key_expired | Ablaufdatum überschritten. |
| 404 | voice_not_found | Nicht zugeteilt, nicht öffentlich oder nicht vorhanden. /voices neu lesen. |
| 404 | model_not_found | Unbekanntes Modell. Erlaubt sind RiftVoiceV1, RiftVoiceV2 und Rift 2.1. |
| 404 | not_found | Pfad tippfehlerverdächtig. |
| 409 | concurrent_limit | Zu viele Läufe gleichzeitig. Kurz warten, dann erneut. |
| 429 | rate_limited | Retry-After abwarten. |
| 429 | quota_exhausted | Tageskontingent leer. resetsAt abwarten oder in der Werkbank erhöhen. |
| 502 | synthesis_failed | Nicht durchgelaufen, nichts abgebucht. Wiederholen ist sinnvoll. |
| 502 | voice_unavailable | Diese Stimme antwortet gerade nicht. Andere versuchen. |
| 503 | service_unavailable | Vorübergehend. Mit wachsendem Abstand wiederholen. |
| 503 | maintenance | Wartung — der ganze Zugang ist zu. Siehe Wartung. |
429, 502 und 503 lohnen einen zweiten Versuch — mit
Abstand, der sich verdoppelt. 4xx außer 429 lohnen keinen:
die Anfrage bleibt falsch, egal wie oft sie kommt.
Zu jeder Antwort gehört eine requestId. Wenn du etwas gemeldet haben möchtest,
nenn sie — damit findet sich der Lauf, ohne dass jemand deinen Schlüssel braucht.
Wartung
Wird die Werkstatt geschlossen, ist der Zugang vollständig zu — jeder Pfad unter
/api, auch /health und der Bauplan. Es gibt in diesem Zustand
keinen Endpunkt, der noch synthetisiert.
{
"error": {
"type": "api_error",
"code": "maintenance",
"message": "Wartung — der Zugang ist voruebergehend geschlossen.",
"detail": "Kurze Wartung — gleich wieder da.",
"retryAfter": 900,
"requestId": "rk9f2c1a4b7d0e3",
"docs": "/api"
}
}
- Status
503, Codemaintenance. Behandle das wieservice_unavailable: warten, dann erneut. Retry-Afterbeachten, wenn ein Ende angekündigt ist. Steht dort nichts, ist das Ende offen — dann mit wachsendem Abstand nachfragen.- Preflight bleibt beantwortet. Ein Browser bekommt darum eine ehrliche
503statt eines nichtssagenden CORS-Fehlers. - Nichts wird abgebucht. Eine abgewiesene Anfrage kostet kein Kontingent.
Antwortköpfe
| Kopf | Wo | Inhalt |
|---|---|---|
X-Rift-Request-Id | überall | Kennung dieses Laufs. |
X-Rift-Voice | /speech | Benutzte Stimmkennung. |
X-Rift-Model | /speech | Modell, das gesprochen hat. |
X-Rift-Chars | /speech | Zeichen im Text. |
X-Rift-Billed-Chars | /speech | Tatsächlich gebuchte Zeichen — bei RiftVoiceV2 das Doppelte, bei Rift 2.1 das Zweieinhalbfache (aufgerundet). |
X-Rift-Bytes | /speech | Größe der MP3. |
X-Rift-Ms | /speech | Dauer in Millisekunden. |
X-Rift-Quota-Remaining | /speech | Rest heute, oder unlimited. |
X-Rift-Quota-Reset | /speech | Wann der Zähler zurückspringt. |
Retry-After | 429 | Sekunden bis zum nächsten Versuch. |
Aus dem Browser sind diese Köpfe lesbar — sie stehen in
Access-Control-Expose-Headers. Cookies gehen dagegen nie mit: die API kennt
keine Sitzung.
Schlüssel sicher halten
- Nur auf dem Server. Umgebungsvariable oder Geheimnisverwaltung — nicht ins Repository, nicht ins Bundle, nicht in ein Log.
- Ein Schlüssel je Zweck. Getrennt für Produktion, Test und jeden Partner. Dann trifft ein Widerruf genau eine Sache.
- So wenig Regal wie nötig. Ein Schlüssel für eine Stimme kann auch nur diese eine.
- Kontingent als Bremse. Das Tageslimit ist die Obergrenze des Schadens, wenn ein Schlüssel doch einmal abhandenkommt.
- Ablaufdatum für Gäste. Wer nur drei Wochen etwas bauen soll, braucht keinen Schlüssel, der ewig gilt.
- Bei Verdacht neu ausstellen. Einstellungen und Zuteilung bleiben, nur das Geheimnis ist ein anderes.
Konsole
Echte Anfragen an diese Instanz. Der Schlüssel bleibt in diesem Tab und wird nirgendwo gespeichert — ein Neuladen leert das Feld.
Die Konsole ruft dieselben Endpunkte auf wie dein Code. Was hier funktioniert, funktioniert dort.