Get API key

Seed Speech 2.0 API

by ByteDance

41 voices with tone set in plain language

bytedance/seed-speech-2.0/text-to-speechFrom 11 creditsper 1,000 characters · $0.11
  • 41 voices
  • 10 languages
  • Text to Speech

Call it with the model name bytedance/seed-speech-2.0/text-to-speech on POST /jobs/createTask, from 11 credits per 1,000 characters ($0.11). New accounts get 30 free credits to try it in the Playground first.

Model namebytedance/seed-speech-2.0/text-to-speech
EndpointPOST https://api.popnow.ai/api/v1/jobs/createTask
# 1. Create the task. Keep the Idempotency-Key if you retry after a network error.
export IDEMPOTENCY_KEY="$(uuidgen)"
curl -X POST https://api.popnow.ai/api/v1/jobs/createTask \
  -H "Authorization: Bearer $POPNOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d '{
  "model": "bytedance/seed-speech-2.0/text-to-speech",
  "input": {
    "text": "Welcome back. Your files finished rendering a minute ago and are ready to download.",
    "voice_id": "stokie_en",
    "output_format": "mp3"
  }
}'

# 2. Copy data.taskId from the response, then poll until state is succeeded, failed, canceled or expired.
TASK_ID="TASK_ID_FROM_RESPONSE"
curl "https://api.popnow.ai/api/v1/jobs/recordInfo?taskId=$TASK_ID" \
  -H "Authorization: Bearer $POPNOW_API_KEY"

The input is built from the schema below, so it passes validation as written. Change the prompt and links to your own.

Price

OptionCredits per 1,000 characters≈ USD
Standard10.8$0.11

1 credit = $0.01 · Every option and its price: GET /models/bytedance/seed-speech-2.0/text-to-speech

Each task is rounded up to whole credits, so one run at the lowest tier costs 11 credits per 1,000 characters ($0.11).

Limits

Text length (characters)
5,000
Voices
41
Languages
10

Parameters

These fields come from the same schema we check every request against. Fields that are not listed are rejected.

  • textstringRequired

    The words to be spoken, exactly as written. Billed per 1,000 characters.

    Range
    1 – 5000 chars
  • voice_idstringOptional

    Pick from the available voices.

    Default
    "stokie_en"
    Allowed values
    "vivi_mixed_en_zh_ja_es_id""mindy_en_es_id_pt_zh""stokie_en""dacey_en""tim_en""kian_en_zh""cedric_en_zh""sophie_en_zh""jean_en_zh""magnus_en_zh""mabel_en_zh""nadia_en_zh""opal_en_zh""pearl_en_zh""quentin_en_zh""vienna_mixed_en_zh""alina_mixed_en_zh""corinne_mixed_en_zh""esther_mixed_en_zh""freya_mixed_en_zh""gigi_mixed_en_zh""holly_mixed_en_zh""lyla_mixed_en_zh""daisy_mixed_en_zh""tracy_es_zh""jess_ja_es_id_pt_en_zh""pinky_es_ko_mixed_en_zh""sweety_ja_es""sandy_es_mixed_en_zh""sven_de""minimi_ja""usseau_fr""felipe_es""han_id""martins_pt""enzo_it""shane_ko""bonnie_zh""felix_zh""celeste_zh"+1
  • instructionsstringOptional

    How to read the text: tone, emotion, pace or volume. Not spoken and not billed.

    Range
    0 – 1000 chars
  • speednumberOptional

    Speaking pace.

    Default
    1
    Range
    0.5 – 2
  • pitchintegerOptional

    Pitch shift in semitones; 0 keeps the voice's own pitch.

    Default
    0
    Range
    -12 – 12
  • volumenumberOptional

    Loudness multiplier; 1 is the normal level.

    Default
    1
    Range
    0.5 – 2
  • languagestringOptional

    The language of the speech.

    Allowed values
    "zh""en""ja""es-mx""id""pt-br""ko""it""de""fr"
  • output_formatstringOptional

    File type of the result.

    Default
    "mp3"
    Allowed values
    "mp3""opus"
  • sample_rateintegerOptional

    Sample rate in Hz. The model uses 24000 when omitted.

    Allowed values
    8000160002205024000320004410048000
Raw JSON Schema
{
  "type": "object",
  "required": [
    "text"
  ],
  "properties": {
    "text": {
      "type": "string",
      "maxLength": 5000,
      "minLength": 1,
      "description": "The words to be spoken, exactly as written. Billed per 1,000 characters."
    },
    "pitch": {
      "type": "integer",
      "default": 0,
      "maximum": 12,
      "minimum": -12,
      "description": "Pitch shift in semitones; 0 keeps the voice's own pitch."
    },
    "speed": {
      "type": "number",
      "default": 1,
      "maximum": 2,
      "minimum": 0.5,
      "description": "Speaking rate; 1 is the voice's normal pace."
    },
    "volume": {
      "type": "number",
      "default": 1,
      "maximum": 2,
      "minimum": 0.5,
      "description": "Loudness multiplier; 1 is the normal level."
    },
    "language": {
      "enum": [
        "zh",
        "en",
        "ja",
        "es-mx",
        "id",
        "pt-br",
        "ko",
        "it",
        "de",
        "fr"
      ],
      "type": "string",
      "description": "Forces the reading language. Leave empty to detect it from the text."
    },
    "voice_id": {
      "enum": [
        "vivi_mixed_en_zh_ja_es_id",
        "mindy_en_es_id_pt_zh",
        "stokie_en",
        "dacey_en",
        "tim_en",
        "kian_en_zh",
        "cedric_en_zh",
        "sophie_en_zh",
        "jean_en_zh",
        "magnus_en_zh",
        "mabel_en_zh",
        "nadia_en_zh",
        "opal_en_zh",
        "pearl_en_zh",
        "quentin_en_zh",
        "vienna_mixed_en_zh",
        "alina_mixed_en_zh",
        "corinne_mixed_en_zh",
        "esther_mixed_en_zh",
        "freya_mixed_en_zh",
        "gigi_mixed_en_zh",
        "holly_mixed_en_zh",
        "lyla_mixed_en_zh",
        "daisy_mixed_en_zh",
        "tracy_es_zh",
        "jess_ja_es_id_pt_en_zh",
        "pinky_es_ko_mixed_en_zh",
        "sweety_ja_es",
        "sandy_es_mixed_en_zh",
        "sven_de",
        "minimi_ja",
        "usseau_fr",
        "felipe_es",
        "han_id",
        "martins_pt",
        "enzo_it",
        "shane_ko",
        "bonnie_zh",
        "felix_zh",
        "celeste_zh",
        "monkey_king_zh"
      ],
      "type": "string",
      "default": "stokie_en",
      "description": "Preset voice. The suffix shows the languages it is built for."
    },
    "sample_rate": {
      "enum": [
        8000,
        16000,
        22050,
        24000,
        32000,
        44100,
        48000
      ],
      "type": "integer",
      "description": "Sample rate in Hz. The model uses 24000 when omitted."
    },
    "instructions": {
      "type": "string",
      "maxLength": 1000,
      "description": "How to read the text: tone, emotion, pace or volume. Not spoken and not billed."
    },
    "output_format": {
      "enum": [
        "mp3",
        "opus"
      ],
      "type": "string",
      "default": "mp3",
      "description": "File format of the result; opus is an Ogg Opus file."
    }
  },
  "additionalProperties": false
}

Responses, polling and callbacks

Every model works the same way: create a task, then poll it or wait for a callback.

Price it first

cURL
curl -X POST https://api.popnow.ai/api/v1/jobs/quote \
  -H "Authorization: Bearer $POPNOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "model": "bytedance/seed-speech-2.0/text-to-speech",
  "input": {
    "text": "Welcome back. Your files finished rendering a minute ago and are ready to download.",
    "voice_id": "stokie_en",
    "output_format": "mp3"
  }
}'

Same body as createTask. It returns the expected and the highest possible charge, and runs nothing.

Accepted

HTTP
HTTP/1.1 202 Accepted

{
  "code": 200,
  "msg": "success",
  "data": {
    "taskId": "task_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "state": "queued"
  },
  "request_id": "req_example_create"
}

HTTP 202 means the task was accepted and the credits are held, not charged yet. The final charge never goes above the hold.

Check status

cURL
curl "https://api.popnow.ai/api/v1/jobs/recordInfo?taskId=$TASK_ID" \
  -H "Authorization: Bearer $POPNOW_API_KEY"

Poll every few seconds with backoff and stop once state is succeeded, failed, canceled or expired.

Finished task

JSON
{
  "code": 200,
  "msg": "success",
  "data": {
    "taskId": "task_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "model": "bytedance/seed-speech-2.0/text-to-speech",
    "state": "succeeded",
    "output": {
      "assets": [
        {
          "url": "https://cdn.popnow.ai/tasks/task_01k3m8x9q2z4v7n5p6r8s0t1w3/output.mp3?signature=FROM_RESPONSE",
          "expiresAt": "2026-10-17T12:20:00Z",
          "mime": "audio/mpeg",
          "durationSeconds": 30
        }
      ]
    },
    "settled": true
  },
  "request_id": "req_example_record"
}

Download from url before expiresAt. Need a fresh link later? Ask POST /common/download-url.

Verify a callback

Node.js
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
// express.raw keeps the exact bytes that were signed; express.json would not match.
app.post("/hooks/popnow", express.raw({ type: "application/json" }), (req, res) => {
  const timestamp = req.header("X-Webhook-Timestamp");
  const signature = req.header("X-Webhook-Signature") ?? "";
  const body = JSON.parse(req.body.toString("utf8"));
  const taskId = body.data?.taskId ?? body.task_id;
  const digest = createHash("sha256").update(req.body).digest("hex");
  const expected = createHmac("sha256", process.env.POPNOW_WEBHOOK_SECRET)
    .update(`${taskId}.${timestamp}.${digest}`)
    .digest("base64");
  const ok = expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  // Check the signature first, then reject anything older than five minutes.
  if (!ok || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.status(401).end();
  res.status(200).end(); // answer fast, then fetch the task and store its files
});

With callBackUrl set, we post the finished task to you. Check the signature against the raw body before you trust it.

Send a local file

cURL
curl -X POST https://api.popnow.ai/api/v1/common/upload-url \
  -H "Authorization: Bearer $POPNOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contentType": "image/png", "bytes": 482113}'

# PUT the file bytes to data.uploadUrl with every header in data.headers, then commit it:
curl -X POST https://api.popnow.ai/api/v1/files/$FILE_ID/commit \
  -H "Authorization: Bearer $POPNOW_API_KEY"
# The commit returns a popnow://f/... reference. Put it in any image, video or
# audio field of the task input instead of a public https URL.

Public https links work as they are. Local files go through upload-url and commit first.