Async Jobs

All batch TTS requests are processed asynchronously. Submit a job, get a job ID, then poll for the audio result or receive it via webhook.

When to Use Async

POST /v1/audio/speech returns 200 with binary audio by default (sync). When you provide a webhook_url parameter, it returns 202 Accepted with a job ID for async processing. Use GET /v1/jobs/{jobId} to poll, or wait for the webhook callback.

How It Works

1

Submit a batch request

POST /v1/audio/speech with a webhook_url parameter returns 202 immediately with a job ID like job_a1b2c3d4e5f6g7h8. Without webhook_url, it returns 200 with binary audio (sync).

2

Job processes on GPU

The job enters the RunPod queue. Status progresses: queued -> processing -> completed or failed.

3

Retrieve the audio

Poll GET /v1/jobs/{jobId} — returns binary audio when complete. Optionally add webhook_url to receive the result via POST.

Submitting a Job

curl -X POST "https://api.murmr.dev/v1/audio/speech" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "A long article to convert to speech...",
    "voice": "voice_abc123",
    "response_format": "mp3"
  }'
# Returns 202:
# {"id": "job_a1b2c3d4e5f6g7h8", "status": "queued", "created_at": "..."}

Optionally add webhook_url to receive the completed audio via POST to your server. See Webhook Delivery below.

Submit Response

202 Acceptedapplication/json
JSON
{
  "id": "job_a1b2c3d4e5f6g7h8",
  "status": "queued",
  "created_at": "2026-02-13T10:30:00.000Z"
}
ParameterTypeDescription
idrequiredstringUnique job identifier (job_ + 16 hex chars). Use this to poll for status.
statusrequiredstringAlways "queued" on submission.
created_atrequiredstringISO 8601 timestamp of when the job was submitted.

Polling for Results

GET/v1/jobs/{jobId}

Returns current job status. Requires the same API key used to submit the job. Only the job owner can access their jobs.

Response varies by status

  • In progress: Returns JSON with status field
  • Completed: Returns binary audio (Content-Type: audio/*) — save directly to file
  • Failed: Returns JSON with error field
  • Expired: Returns 410 Gone if audio has been purged

In-Progress Response

JSON
{
  "id": "job_a1b2c3d4e5f6g7h8",
  "status": "processing",
  "created_at": "2026-02-13T10:30:00.000Z"
}

Completed Response

200 OKaudio/mpeg (or requested format)

Binary audio data. Check the Content-Type header to determine the format. Response headers include X-Audio-Duration-Ms and X-Total-Time-Ms.

Failed Response

JSON
{
  "id": "job_a1b2c3d4e5f6g7h8",
  "status": "failed",
  "error": "Voice not found: voice_invalid",
  "created_at": "2026-02-13T10:30:00.000Z"
}

Polling Example

import { MurmrClient, MurmrError } from '@murmr/sdk';
import { writeFileSync } from 'fs';

const client = new MurmrClient({ apiKey: process.env.MURMR_API_KEY! });

// Option 1: Manual polling
const status = await client.jobs.get('job_a1b2c3d4e5f6g7h8');

if (status.status === 'completed' && status.audio_base64) {
  const audio = Buffer.from(status.audio_base64, 'base64');
  writeFileSync(`output.${status.response_format || 'wav'}`, audio);
}

// Option 2: Wait for completion (polls automatically)
try {
  const result = await client.jobs.waitForCompletion('job_a1b2c3d4e5f6g7h8', {
    pollIntervalMs: 3000,
    timeoutMs: 900_000,
    onPoll: (status) => console.log(`Status: ${status.status}`),
  });

  if (result.audio_base64) {
    writeFileSync('output.wav', Buffer.from(result.audio_base64, 'base64'));
  }
} catch (error) {
  if (error instanceof MurmrError && error.code === 'JOB_FAILED') {
    console.error('Job failed:', error.message);
  }
}

// Option 3: Submit and wait in one call
const final = await client.speech.createAndWait({
  input: 'Submit and wait in one call.',
  voice: 'voice_abc123',
  response_format: 'mp3',
});

Polling Best Practice

Poll every 3-5 seconds. Job metadata expires after 24 hours. Audio data on the backend may be purged sooner — retrieve promptly after completion to avoid a 410 Gone response.

Webhook Delivery

Add webhook_url to your request to receive the audio via POST when the job completes. Polling still works as a fallback.

cURL
curl -X POST "https://api.murmr.dev/v1/audio/speech" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Text to synthesize...",
    "voice": "voice_abc123",
    "response_format": "mp3",
    "webhook_url": "https://your-server.com/webhooks/tts"
  }'

Success Payload

JSON
{
  "id": "job_a1b2c3d4e5f6g7h8",
  "status": "completed",
  "audio": "<base64-encoded audio>",
  "content_type": "audio/mpeg",
  "response_format": "mp3",
  "duration_ms": 4520,
  "total_time_ms": 3200
}

Failure Payload

JSON
{
  "id": "job_a1b2c3d4e5f6g7h8",
  "status": "failed",
  "error": "Voice not found: voice_invalid"
}

Webhook Handler Example

import base64
from flask import Flask, request

app = Flask(__name__)

@app.route("/webhooks/tts", methods=["POST"])
def handle_tts_webhook():
    payload = request.json

    if payload["status"] == "completed":
        audio_bytes = base64.b64decode(payload["audio"])
        filename = f"{payload['id']}.{payload['response_format']}"

        with open(filename, "wb") as f:
            f.write(audio_bytes)

        print(f"Saved {filename} ({payload['duration_ms']}ms audio)")
    else:
        print(f"Job failed: {payload.get('error')}")

    return "OK", 200

Webhook Requirements

  • HTTPS only — webhook_url must use HTTPS protocol
  • Max URL length — 2,048 characters
  • No private IPs — localhost, 127.0.0.1, 10.x, 172.16-31.x, 192.168.x are blocked
  • No internal domains — murmr.dev subdomains are blocked
  • Payload size — audio is base64-encoded (~1.33x raw file size). Set your server's body size limit accordingly.

Job ID Format

Job IDs follow the pattern job_ followed by 16 hexadecimal characters:

Regex
/^job_[a-f0-9]{16}$/

Example: job_a1b2c3d4e5f6g7h8

Job Status Values

StatusDescription
queuedJob is waiting for an available GPU worker
processingGPU worker is generating audio
completedAudio generated — poll to download, or check webhook
failedJob failed — check the error field for details

Job metadata expires after 24 hours. Audio data on the backend may be purged sooner. Retrieve completed audio promptly.

Error Responses

400

Bad Request

Invalid webhook_url (not HTTPS, private IP, too long), invalid response_format, missing text

404

Not Found

Job ID not found, expired (24h TTL), or belongs to a different account

410

Gone

Job completed but audio data has been purged from the backend. Re-submit the job.

429

Server Busy

GPU queue is full. Check the Retry-After header.

503

Service Unavailable

Batch processing is temporarily unavailable

See Also