SPEECH2TEXT Документация API

Speech2Text API - Полная документация

Базовый URL

http://localhost:8020

Аутентификация

Все защищенные endpoints требуют JWT токен в заголовке Authorization:

Authorization: Bearer YOUR_JWT_TOKEN

Для программного доступа в том же заголовке передаётся API-ключ:

Authorization: Bearer sk-XXXXXXXXXXXXXXXX...

Ключу можно задать секретное слово и потребовать, чтобы каждый запрос этим ключом был подписан, — см. Подпись HMAC-SHA256. По умолчанию подпись выключена, и ключ работает как раньше.


Содержание

  1. Аутентификация
  2. Транскрибация
  3. Управление пользователями
  4. API ключи
  5. Метрики
  6. Администрирование
  7. Загрузка по ссылке и обратный вызов
  8. Подпись HMAC-SHA256

1. Аутентификация

Вход в систему

POST /auth/login

Получение JWT токена для доступа к API.

Параметры запроса:

{
  "username": "string",
  "password": "string"
}

Ответ:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer"
}

Примеры:

Python:

import requests

url = "http://localhost:8020/auth/login"
data = {
    "username": "admin",
    "password": "admin123"
}

response = requests.post(url, json=data)
token = response.json()["access_token"]
print(f"Token: {token}")

PHP:

<?php
$url = "http://localhost:8020/auth/login";
$data = array(
    "username" => "admin",
    "password" => "admin123"
);

$options = array(
    'http' => array(
        'header'  => "Content-type: application/json\r\n",
        'method'  => 'POST',
        'content' => json_encode($data)
    )
);

$context  = stream_context_create($options);
$result = file_get_contents($url, false, $context);
$response = json_decode($result, true);
$token = $response["access_token"];
echo "Token: " . $token;
?>

JavaScript:

async function login() {
    const response = await fetch('http://localhost:8020/auth/login', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            username: 'admin',
            password: 'admin123'
        })
    });

    const data = await response.json();
    const token = data.access_token;
    console.log('Token:', token);
    return token;
}

cURL:

curl -X POST "http://localhost:8020/auth/login" \
     -H "Content-Type: application/json" \
     -d '{"username":"admin","password":"admin123"}'

Регистрация пользователя

POST /auth/register

Создание нового пользователя.

Параметры запроса:

{
  "username": "string",
  "email": "user@example.com",
  "password": "string"
}

Ответ:

{
  "id": "uuid",
  "username": "string",
  "email": "user@example.com",
  "is_active": true,
  "is_admin": false,
  "created_at": "2024-01-01T00:00:00"
}

Примеры:

Python:

import requests

url = "http://localhost:8020/auth/register"
data = {
    "username": "newuser",
    "email": "newuser@example.com",
    "password": "secure_password123"
}

response = requests.post(url, json=data)
user = response.json()
print(f"Created user: {user['username']} with ID: {user['id']}")

JavaScript:

async function registerUser() {
    const response = await fetch('http://localhost:8020/auth/register', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            username: 'newuser',
            email: 'newuser@example.com',
            password: 'secure_password123'
        })
    });

    const user = await response.json();
    console.log('Created user:', user);
    return user;
}

Получить текущего пользователя

GET /auth/me

Получение информации о текущем авторизованном пользователе.

Заголовки:

Authorization: Bearer YOUR_JWT_TOKEN

Ответ:

{
  "id": "uuid",
  "username": "string",
  "email": "user@example.com",
  "is_active": true,
  "is_admin": false,
  "created_at": "2024-01-01T00:00:00"
}

Примеры:

Python:

import requests

url = "http://localhost:8020/auth/me"
headers = {
    "Authorization": f"Bearer {token}"
}

response = requests.get(url, headers=headers)
user = response.json()
print(f"Current user: {user['username']}")

2. Транскрибация

Что можно загружать: форматы, видео, размер

Принимаются расширения .mp3, .mp4, .wav, .m4a, .ogg, .flac, .mp2, .mov, .mxf, .webm. Всё, что ниже, одинаково относится и к POST /transcribe/, и к POST /transcribe/async, и к загрузке по ссылке (url).

Видеофайлы отдавайте как есть. .mov, .mxf, .webm (и .mp4) — это видеоконтейнеры; звук из них извлекает сам сервис через ffmpeg. Отдельно вырезать звуковую дорожку перед загрузкой не нужно.

Несколько звуковых дорожек сводятся в одну. Вещательный MXF обычно несёт по отдельной моно-дорожке на микрофон. Сервис смешивает все дорожки (с усреднением громкости), а не выбирает первую попавшуюся, — иначе целый микрофон, то есть целый говорящий, выпал бы из расшифровки. Одинаковые дорожки при сведении звучат ровно как исходная, перегрузки по громкости не возникает. Выбрать конкретную дорожку через API нельзя.

Размер. Лимит — MAX_FILE_SIZE_MB, по умолчанию 5000 МБ (раньше 500). Видеоконтейнер с материалом целиком — это гигабайты, тогда как звук в нём занимает проценты; раз извлечением занимается сервис, лимит пропускает исходник. Тем же лимитом ограничена и загрузка по ссылке. Превышение — 413.

Файл без звука. Если в файле нет ни одной звуковой дорожки, задача завершается со статусом failed, а в error_message приходит: «В файле нет звуковой дорожки — распознавать нечего.»


Транскрибировать аудио

POST /transcribe/

Загрузка и транскрибация аудио- или видеофайла.

Параметры:

Ответ:

{
  "id": "uuid",
  "filename": "audio.mp3",
  "text": "Транскрибированный текст...",
  "language": "ru",
  "duration": 120.5,
  "processing_time": 15.3,
  "status": "completed",
  "created_at": "2024-01-01T00:00:00",
  "segments": [
    {
      "id": 0,
      "start": 0.0,
      "end": 5.2,
      "text": "Первый сегмент текста",
      "speaker": "SPEAKER_01",  // Только если diarize=true
      "words": [  // Только если return_timestamps=true
        {
          "word": "Первый",
          "start": 0.0,
          "end": 0.8,
          "probability": 0.95
        },
        {
          "word": "сегмент",
          "start": 0.8,
          "end": 1.5,
          "probability": 0.98
        }
      ]
    }
  ],
  "has_diarization": true,  // Только если diarize=true
  "diarization": {          // Только если diarize=true
    "num_speakers": 2,
    "speakers": [
      {"id": "SPEAKER_01", "label": "Собеседник 1", "talk_time": 75.4, "segments": 12},
      {"id": "SPEAKER_02", "label": "Собеседник 2", "talk_time": 44.1, "segments": 11}
    ],
    "text_with_speakers": "Собеседник 1: Здравствуйте!...\n\nСобеседник 2: Добрый день..."
  }
}

В ответе (и в GET /transcriptions/{id}, и в теле обратного вызова) присутствуют ещё поля источника и доставки — они заполняются, только если запись пришла по ссылке и/или был заказан callback:

Поле Значение
source_url Ссылка, с которой скачано аудио, с вырезанными секретами (null для загрузки файлом)
callback_url Куда доставляется результат (null, если вызов не заказан)
callback_status null · pending · sending · delivered · failed
callback_attempts Сколько попыток доставки сделано
callback_error Причина последней неудачи
callback_delivered_at Когда доставлено (UTC)

Пример с диаризацией (разделение собеседников):

data = {
    "language": "ru",
    "diarize": "true",       # Включить разделение по собеседникам
    "num_speakers": "2"      # Опционально: точное число собеседников
}
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()

print(result["diarization"]["text_with_speakers"])  # Готовый диалог
for seg in result["segments"]:
    print(f"[{seg.get('speaker')}] {seg['start']:.1f}-{seg['end']:.1f}: {seg['text']}")

Примеры:

Python:

import requests

url = "http://localhost:8020/transcribe/"
headers = {
    "Authorization": f"Bearer {token}"
}

with open("audio.mp3", "rb") as audio_file:
    files = {"file": audio_file}
    data = {
        "language": "ru",
        "return_segments": "true",
        "return_timestamps": "true"  # Включить пословные метки времени
    }

    response = requests.post(url, headers=headers, files=files, data=data)
    result = response.json()

    print(f"Transcription ID: {result['id']}")
    print(f"Text: {result['text'][:100]}...")
    print(f"Duration: {result['duration']} seconds")

    # Обработка меток времени
    if "segments" in result:
        for segment in result["segments"]:
            print(f"\nSegment {segment['id']}: {segment['start']:.1f}s - {segment['end']:.1f}s")
            if "words" in segment:
                for word in segment["words"]:
                    print(f"  {word['word']}: {word['start']:.1f}s - {word['end']:.1f}s")

PHP:

<?php
$url = "http://localhost:8020/transcribe/";
$token = "YOUR_JWT_TOKEN";

$file = new CURLFile('audio.mp3');
$data = array(
    'file' => $file,
    'language' => 'ru',
    'return_segments' => 'false'
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
    "Authorization: Bearer " . $token
));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);

$result = json_decode($response, true);
echo "Text: " . $result['text'];
?>

JavaScript:

async function transcribeAudio(file, token) {
    const formData = new FormData();
    formData.append('file', file);
    formData.append('language', 'ru');
    formData.append('return_segments', 'false');

    const response = await fetch('http://localhost:8020/transcribe/', {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${token}`
        },
        body: formData
    });

    const result = await response.json();
    console.log('Transcription:', result.text);
    return result;
}

// Использование с input файла
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', async (e) => {
    const file = e.target.files[0];
    await transcribeAudio(file, token);
});

cURL:

curl -X POST "http://localhost:8020/transcribe/" \
     -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     -F "file=@audio.mp3" \
     -F "language=ru" \
     -F "return_segments=false"

⏳ Асинхронная транскрибация (очередь)

POST /transcribe/async

Ставит запись в очередь и сразу возвращает задачу со статусом pending, не удерживая соединение. Параметры — те же, что у /transcribe/, включая url и callback_url.

Именно здесь пара url + callback_url работает как задумано: клиент отдаёт ссылку на запись и адрес приёмника, получает id и больше ничего не опрашивает — готовый текст приедет сам (см. раздел 7).

Все загрузки (и синхронные, и асинхронные) проходят через одну персистентную очередь: задача хранится в базе вместе с параметрами запуска, поэтому перезапуск или отключение питания её не теряет — при следующем старте сервер сам возвращает прерванные задачи в очередь и доводит их до конца. Одновременно обрабатывается одна запись, остальные ждут своей очереди.

Ответ (202 Accepted):

{
  "id": "uuid",
  "filename": "audio.mp3",
  "status": "pending",
  "text": null,
  "created_at": "2026-07-27T17:31:17"
}

Жизненный цикл задачи:

pending → processing → completed | failed | cancelled

Прогресс и позицию в очереди отдаёт GET /transcriptions/{id}/status, готовый результат — GET /transcriptions/{id}.

Пример:

import requests, time

headers = {"Authorization": f"Bearer {token}"}

with open("audio.mp3", "rb") as f:
    job = requests.post(
        "http://localhost:8020/transcribe/async",
        headers=headers,
        files={"file": f},
        data={"language": "ru", "return_timestamps": True},
    ).json()

while True:
    state = requests.get(
        f"http://localhost:8020/transcriptions/{job['id']}/status", headers=headers
    ).json()
    if state["status"] in ("completed", "failed", "cancelled"):
        break
    print(f"{state['status']}: {state['progress']}%, в очереди перед вами: {state['queue_position']}")
    time.sleep(5)

result = requests.get(
    f"http://localhost:8020/transcriptions/{job['id']}", headers=headers
).json()
print(result["text"])

Синхронный POST /transcribe/ работает как раньше и возвращает готовый текст, но если обработка не уложилась в QUEUE_SYNC_WAIT_SECONDS (по умолчанию час), он ответит 504 с id задачи — задача при этом не теряется и продолжает выполняться.


История транскрибаций

GET /transcribe/history

Получение списка всех транскрибаций пользователя.

Параметры:

Ответ:

[
  {
    "id": "uuid",
    "filename": "audio.mp3",
    "status": "completed",
    "duration": 120.5,
    "language": "ru",
    "created_at": "2024-01-01T00:00:00"
  }
]

Примеры:

Python:

import requests

url = "http://localhost:8020/transcribe/history"
headers = {
    "Authorization": f"Bearer {token}"
}
params = {
    "skip": 0,
    "limit": 50
}

response = requests.get(url, headers=headers, params=params)
history = response.json()

for item in history:
    print(f"{item['filename']} - {item['status']} - {item['created_at']}")

Получить транскрибацию

GET /transcribe/{transcription_id}

Получение полной информации о конкретной транскрибации.

Ответ:

{
  "id": "uuid",
  "filename": "audio.mp3",
  "text": "Полный текст транскрибации...",
  "language": "ru",
  "duration": 120.5,
  "processing_time": 15.3,
  "status": "completed",
  "created_at": "2024-01-01T00:00:00",
  "segments": []
}

Стриминг аудио

GET /transcriptions/{transcription_id}/audio

Получение аудиофайла для воспроизведения.

Примеры:

Python:

import requests

url = f"http://localhost:8020/transcriptions/{transcription_id}/audio"
headers = {
    "Authorization": f"Bearer {token}"
}

response = requests.get(url, headers=headers, stream=True)

# Сохранить аудио
with open("downloaded_audio.mp3", "wb") as f:
    for chunk in response.iter_content(chunk_size=8192):
        f.write(chunk)

JavaScript:

async function playAudio(transcriptionId, token) {
    const response = await fetch(
        `http://localhost:8020/transcriptions/${transcriptionId}/audio`,
        {
            headers: {
                'Authorization': `Bearer ${token}`
            }
        }
    );

    const blob = await response.blob();
    const audioUrl = URL.createObjectURL(blob);

    const audio = new Audio(audioUrl);
    audio.play();
}

Статус транскрибации

GET /transcriptions/{transcription_id}/status

Проверка статуса обработки транскрибации.

Ответ:

{
  "id": "uuid",
  "status": "processing",
  "progress": 42,
  "queue_position": null,
  "attempts": 1,
  "filename": "audio.mp3",
  "created_at": "2024-01-01T00:00:00",
  "queued_at": "2024-01-01T00:00:00",
  "started_at": "2024-01-01T00:00:05",
  "completed_at": null,
  "duration": null,
  "processing_time": null,
  "error_message": null
}

Примеры:

Python:

import requests
import time

def wait_for_transcription(transcription_id, token):
    url = f"http://localhost:8020/transcriptions/{transcription_id}/status"
    headers = {"Authorization": f"Bearer {token}"}

    while True:
        response = requests.get(url, headers=headers)
        status = response.json()

        if status['status'] == 'completed':
            print("Transcription completed!")
            return True
        elif status['status'] == 'failed':
            print(f"Transcription failed: {status['error_message']}")
            return False

        print(f"Status: {status['status']}... waiting")
        time.sleep(2)

Скачать транскрибацию

GET /transcriptions/{transcription_id}/download

Скачивание текста транскрибации в различных форматах.

Параметры:

Примеры:

Python:

import requests

# Скачать как текст
url = f"http://localhost:8020/transcriptions/{transcription_id}/download?format=txt"
headers = {"Authorization": f"Bearer {token}"}

response = requests.get(url, headers=headers)
with open("transcription.txt", "w", encoding="utf-8") as f:
    f.write(response.text)

# Скачать как субтитры SRT
url = f"http://localhost:8020/transcriptions/{transcription_id}/download?format=srt"
response = requests.get(url, headers=headers)
with open("transcription.srt", "w", encoding="utf-8") as f:
    f.write(response.text)

3. Управление пользователями

Список пользователей (Admin)

GET /admin/users

Получение списка всех пользователей (требуются права администратора).

Ответ:

[
  {
    "id": "uuid",
    "username": "user1",
    "email": "user1@example.com",
    "is_active": true,
    "is_admin": false,
    "created_at": "2024-01-01T00:00:00"
  }
]

Обновить пользователя (Admin)

PATCH /admin/users/{user_id}

Обновление данных пользователя.

Параметры:

{
  "username": "new_username",
  "email": "new@example.com",
  "password": "new_password",
  "is_active": true,
  "is_admin": false
}

Примеры:

Python:

import requests

url = f"http://localhost:8020/admin/users/{user_id}"
headers = {
    "Authorization": f"Bearer {admin_token}",
    "Content-Type": "application/json"
}
data = {
    "is_active": False,
    "is_admin": True
}

response = requests.patch(url, headers=headers, json=data)
updated_user = response.json()
print(f"Updated user: {updated_user}")

Удалить пользователя (Admin)

DELETE /admin/users/{user_id}

Удаление пользователя из системы.

Примеры:

JavaScript:

async function deleteUser(userId, token) {
    const response = await fetch(`http://localhost:8020/admin/users/${userId}`, {
        method: 'DELETE',
        headers: {
            'Authorization': `Bearer ${token}`
        }
    });

    if (response.ok) {
        console.log('User deleted successfully');
    } else {
        console.error('Failed to delete user');
    }
}

4. API Ключи

Создать API ключ

POST /api-keys/

Создание нового API ключа для программного доступа.

Параметры:

{
  "name": "My Application",
  "permissions": ["transcribe", "read"],
  "rate_limit": 100,
  "expires_in_days": 365,

  "webhook_secret": null,
  "generate_webhook_secret": false,
  "sign_callbacks": false,
  "require_request_signature": false
}
Поле подписи По умолчанию Описание
webhook_secret null Своё секретное слово, от 8 до 256 символов (иначе 422)
generate_webhook_secret false Пусть секрет придумает сервер: whsec_ + 43 случайных символа
sign_callbacks false Подписывать исходящие вызовы на callback_url
require_request_signature false Требовать подпись у входящих запросов этим ключом

Задать одновременно webhook_secret и generate_webhook_secret нельзя, включить любой из выключателей без секретного слова — тоже (400, тексты — в разделе 8).

Ответ:

{
  "id": "uuid",
  "name": "My Application",
  "api_key": "sk-abcdef123456789...",
  "key_prefix": "sk-abcdef1...",
  "is_active": true,
  "permissions": ["transcribe", "read"],
  "rate_limit": 100,
  "created_at": "2024-01-01T00:00:00",
  "last_used_at": null,
  "expires_at": "2025-01-01T00:00:00",

  "sign_callbacks": false,
  "require_request_signature": false,
  "webhook_secret_set": false,
  "webhook_secret_hint": null,
  "webhook_secret": null
}

Важно: Полный ключ (api_key) показывается только один раз при создании!

Поле webhook_secret заполняется, только если секрет был задан или сгенерирован этим же вызовом: его надо увидеть один раз, чтобы передать интеграции. В списке ключей его больше не будет — там видно лишь webhook_secret_set (есть ли секрет) и маску webhook_secret_hint (whsec_••••••••). Полное значение отдаёт отдельная ручка GET /api-keys/{key_id}/webhook-secret.

Примеры:

Python:

import requests

url = "http://localhost:8020/api-keys/"
headers = {
    "Authorization": f"Bearer {token}",
    "Content-Type": "application/json"
}
data = {
    "name": "Python Client",
    "permissions": ["transcribe"],
    "rate_limit": 1000,
    "expires_in_days": 90
}

response = requests.post(url, headers=headers, json=data)
api_key = response.json()

print(f"API Key created: {api_key['key']}")
print("Save this key securely! It won't be shown again.")

# Использование API ключа вместо JWT токена
api_headers = {
    "X-API-Key": api_key['key']
}

Список API ключей

GET /api-keys/

Получение списка всех API ключей пользователя.

Ответ:

[
  {
    "id": "uuid",
    "name": "My Application",
    "key_prefix": "sk-abcdef1...",
    "is_active": true,
    "permissions": ["transcribe", "read"],
    "rate_limit": 100,
    "created_at": "2024-01-01T00:00:00",
    "last_used_at": "2024-01-15T12:00:00",
    "expires_at": "2025-01-01T00:00:00",
    "sign_callbacks": true,
    "require_request_signature": false,
    "webhook_secret_set": true,
    "webhook_secret_hint": "whsec_••••••••"
  }
]

Сам секрет в список не попадает никогда — только признак его наличия и маска.


Изменить ключ (имя и настройки подписи)

PATCH /api-keys/{key_id}

Меняет только переданные поля; чего нет в теле — остаётся как было. Доступны только собственные ключи пользователя, иначе 404 {"detail": "API-ключ не найден"}.

Параметры:

{
  "name": "Production key",
  "webhook_secret": "my-shared-secret",
  "generate_webhook_secret": false,
  "sign_callbacks": true,
  "require_request_signature": false
}
Поле Значение
name Новое имя. null — не менять
webhook_secret Новое секретное слово. null — не менять, "" — удалить секрет
generate_webhook_secret true — сервер сам придумает новый секрет и вернёт его в ответе
sign_callbacks true/false — включить/выключить подпись callback. null — не менять
require_request_signature true/false — требовать подпись входящих. null — не менять

Ответ:

Запись ключа (APIKeyFullResponse, то есть с полем api_key) с актуальными sign_callbacks, require_request_signature, webhook_secret_set, webhook_secret_hint. Поле webhook_secret заполняется только при generate_webhook_secret: true — своё слово вызывающий и так знает, а сгенерированное больше нигде не показать.

Ошибки 400:

Текст Когда
Укажите либо своё секретное слово, либо генерацию — но не оба сразу Переданы и webhook_secret, и generate_webhook_secret: true
Включить подпись нельзя: у ключа не задано секретное слово Итоговое состояние — выключатель включён, секрета нет
Сначала выключите подпись — без секретного слова она не работает webhook_secret: "" при хотя бы одном включённом выключателе

Чтобы удалить секрет, гасите подпись тем же запросом:

curl -X PATCH "http://localhost:8020/api-keys/$KEY_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"webhook_secret":"","sign_callbacks":false,"require_request_signature":false}'

Показать секретное слово

GET /api-keys/{key_id}/webhook-secret

Возвращает секрет полностью — его нужно как-то передать той стороне, которая будет проверять подписи. Только для своих ключей (иначе 404). Факт раскрытия попадает в лог сервера (кто и у какого ключа смотрел; само значение в лог не пишется).

Ответ:

{
  "id": "uuid",
  "webhook_secret": "whsec_2Vd1kZ0oQ7pR..."
}

Если секрет не задан — "webhook_secret": null.


5. Метрики

Системные метрики

GET /metrics/system

Получение метрик системы и статистики использования.

Ответ:

{
  "cpu_usage": 45.2,
  "memory_usage": 62.8,
  "disk_usage": 35.1,
  "gpu_available": true,
  "gpu_memory_used": "8.5 GB",
  "whisper_model": "large-v3",
  "active_users": 12,
  "total_transcriptions_24h": 156,
  "average_processing_time": 12.3,
  "uptime_seconds": 86400
}

Примеры:

Python:

import requests

url = "http://localhost:8020/metrics/system"
headers = {"Authorization": f"Bearer {token}"}

response = requests.get(url, headers=headers)
metrics = response.json()

print(f"CPU Usage: {metrics['cpu_usage']}%")
print(f"GPU Available: {metrics['gpu_available']}")
print(f"Model: {metrics['whisper_model']}")
print(f"Transcriptions (24h): {metrics['total_transcriptions_24h']}")

Пользовательские метрики

GET /metrics/user

Получение статистики использования для текущего пользователя.

Ответ:

{
  "total_transcriptions": 523,
  "total_duration_seconds": 125840.5,
  "total_characters": 2456789,
  "total_api_calls": 1523,
  "monthly_transcriptions": 45,
  "monthly_duration_seconds": 10234.2,
  "monthly_characters": 234567,
  "monthly_api_calls": 156,
  "last_reset_date": "2024-01-01T00:00:00"
}

6. Администрирование

Все транскрибации (Admin)

GET /admin/transcriptions

Получение списка всех транскрибаций в системе.

Ответ:

[
  {
    "id": "uuid",
    "filename": "audio.mp3",
    "status": "completed",
    "duration": 120.5,
    "language": "ru",
    "created_at": "2024-01-01T00:00:00",
    "user_id": "uuid",
    "username": "user1"
  }
]

7. Загрузка по ссылке и обратный вызов

Оба эндпоинта транскрибации — POST /transcribe/ и POST /transcribe/async — принимают два дополнительных поля формы: url (откуда взять аудио) и callback_url (куда отдать результат). Вместе они закрывают сценарий «отдал ссылку — забыл»: клиенту не нужно ни держать соединение с файлом на сотни мегабайт, ни опрашивать статус.

Ссылка вместо файла (url)

curl -X POST "http://localhost:8020/transcribe/async" \
  -H "Authorization: Bearer sk-XXXXXXXX..." \
  -F "url=https://example.com/records/call-2026-09-03.mp3" \
  -F "language=ru"

Правила:

Ошибки скачивания:

Код Когда
400 Не та схема, битый URL, 4xx от источника, пустой ответ, неподдерживаемое расширение
403 Адрес запрещён политикой хостов (см. предупреждение ниже)
413 Аудио больше MAX_FILE_SIZE_MB
502 Источник недоступен или ответил 5xx
504 Источник не уложился в FETCH_TIMEOUT_SEC

⚠️ Внутренняя сеть. По умолчанию FETCH_ALLOW_PRIVATE_HOSTS=false: адреса из приватных, loopback- и link-local-диапазонов запрещены и как источник (url), и как приёмник (callback_url). Проверяется каждый редирект, а имя резолвится один раз, и соединение идёт именно по проверенному адресу — иначе смена DNS между проверкой и подключением обходила бы политику. Включённый FETCH_ALLOW_PRIVATE_HOSTS=true превращает любой API-ключ в HTTP-прокси внутрь вашего периметра: держатель ключа сможет заставить сервис ходить по внутренним адресам. Включайте, только если держатели ключей и так имеют доступ в эту сеть — например, когда записи лежат на файловом сервере в LAN.

source_url сохраняется и отдаётся с вырезанными секретами: ?X-Amz-Signature=…, ?token=…, https://user:pass@host/… и подобное заменяется на REDACTED. Presigned-ссылке нечего делать ни в истории, ни в теле обратного вызова на чужом приёмнике.

Обратный вызов (callback_url)

curl -X POST "http://localhost:8020/transcribe/async" \
  -H "Authorization: Bearer sk-XXXXXXXX..." \
  -F "url=https://example.com/records/call.mp3" \
  -F "callback_url=https://example.com/hooks/asr.php" \
  -F "language=ru" \
  -F "return_segments=true"

Адрес проверяется сразу при постановке (та же политика хостов, что и для url): опечатка — это 400 с телом {"detail": "callback_url: ..."} на том запросе, который её сделал, а не молчаливый провал через десять минут.

Когда задача доходит до конечного состояния — completed, failed или cancelled — сервис отправляет POST:

POST /hooks/asr.php HTTP/1.1
Content-Type: application/json; charset=utf-8
X-ASR-Task-Id: 9191b72b-23d1-4bc8-b2e9-24075e66a5af
X-ASR-Event: transcription.completed
User-Agent: Speech2Text ASR callback

Тело — тот же JSON, что отдаёт GET /transcriptions/{id} (text, language, duration, segments, diarization, поля доставки). Приёмник получает его в php://input — в $_POST его не будет, это JSON, а не форма.

Подпись вызова. Если у API-ключа, создавшего задачу, задано секретное слово и включён sign_callbacks, к заголовкам добавляются X-Webhook-Timestamp и X-Webhook-Signature — см. раздел 8. Ключ без секрета или с выключенным sign_callbacks шлёт вызовы без этих заголовков, как раньше.

Повторы. Доставкой занимается отдельный фоновый поток, который раз в CALLBACK_POLL_SECONDS (по умолчанию 2 с) забирает готовые задачи с заказанным вызовом. Ответ 2xx — доставлено. Иначе делается до CALLBACK_RETRIES повторов (по умолчанию 3) с паузами 2 / 10 / 60 секунд, то есть всего до четырёх попыток; после этого callback_status становится failed, а причина остаётся в callback_error (из тела ответа приёмника читается не больше 2 КБ). Таймаут одной попытки — CALLBACK_TIMEOUT_SEC (30 с).

Неудачная доставка никогда не меняет статус самой задачи: аудио распознано, текст лежит в базе и доступен по GET /transcriptions/{id}, доставка — отдельное дело.

Редиректы не выполняются. Ответ 3xx от приёмника — это неудача с понятным текстом (callback_url redirected to '…'; redirects are not followed — register the final URL), а не переход: 301/302 молча превратили бы POST в GET без тела, а 307 отправил бы всю транскрипцию на хост, который политику хостов не проходил. Регистрируйте конечный адрес.

Адрес проверяется дважды — при постановке и ещё раз непосредственно перед каждой отправкой: между ними проходят минуты, и имя за это время могло быть перенаправлено.

Если сервис перезапустился, пока доставка была в полёте, вызовы, застрявшие в sending, возвращаются в pending при старте и уходят заново — «в процессе отправки» не остаётся навсегда.

Ход доставки виден в GET /transcriptions/{id}:

Поле Значение
callback_status null (вызов не заказан) · pending · sending · delivered · failed
callback_attempts Сколько попыток сделано
callback_error Причина последней неудачи
callback_delivered_at Когда доставлено (UTC)

Приёмник должен быть готов к повтору уже доставленного вызова (сеть могла оборвать ответ): идентификатор задачи в X-ASR-Task-Id и поле id в теле — то, по чему дубль опознаётся.


8. Подпись HMAC-SHA256

Помимо Bearer-ключа сервис умеет подписывать трафик по схеме HMAC-SHA256 + Base64. Подпись настраивается поключево: владелец задаёт секретное слово конкретному API-ключу и независимо включает два выключателя.

Выключатель Поле ключа Что делает
Подписывать callback sign_callbacks Сервис добавляет X-Webhook-Timestamp / X-Webhook-Signature к исходящему POST на callback_url, чтобы приёмник мог убедиться: вызов пришёл именно отсюда
Требовать подпись запросов require_request_signature Сервис отвергает неподписанные запросы этим ключом: украденный ключ без секретного слова бесполезен

ℹ️ Оба выключателя по умолчанию выключены и независимы друг от друга. Обновление ничего не меняет для существующих интеграций: пока ключу не задано секретное слово и не включён соответствующий флаг, ни один запрос и ни один callback не требует подписи. Включить подпись, не задав секрет, нельзя — API управления ключами вернёт 400.

Секретное слово

Секрет привязан к API-ключу, а не к сервису целиком: у каждой интеграции он свой, и компрометация одной не затрагивает остальных. Задать его можно двумя способами:

Наружу секрет отдаётся только владельцу ключа: в списке видно лишь webhook_secret_set и маску webhook_secret_hint (whsec_••••••••), полное значение возвращает GET /api-keys/{key_id}/webhook-secret. В логи и в тексты ошибок секрет не попадает никогда.

Секрет хранится в базе в открытом виде — иначе им нельзя было бы считать HMAC: это ключ подписи, а не пароль, и из хеша его не восстановить.

Заголовки

Заголовок Направление Значение
X-Signature-Timestamp клиент → сервис Unix-время в секундах (целое) на момент отправки запроса
X-Signature клиент → сервис base64(HMAC-SHA256(timestamp + "." + МЕТОД + "." + путь, секрет))
X-Webhook-Timestamp сервис → приёмник callback Unix-время в секундах на момент этой попытки доставки
X-Webhook-Signature сервис → приёмник callback base64(HMAC-SHA256(id задачи + "." + timestamp, секрет))

Обе схемы — одна и та же операция: HMAC-SHA256 от строки, ключ — секретное слово в UTF-8, результат — Base64 от сырых байт дайджеста (не от hex-строки). Различаются только подписываемые строки.

Схема 1. Входящий запрос

Подписывается строка

<timestamp>.<МЕТОД>.<путь со строкой запроса>

Пример подписываемой строки для POST /transcribe/async:

1769670760.POST./transcribe/async

и для GET /transcriptions/history?skip=0&limit=50:

1769670760.GET./transcriptions/history?skip=0&limit=50

⚠️ Путь и строка запроса подписываются «как есть». Сервис берёт их из самой строки запроса HTTP, в неразобранном виде; заголовок Host на подпись не влияет. URL нужно собрать самому и передать библиотеке готовым: если отдать параметры отдельным словарём (params= в requests, http_build_query в PHP), библиотека может закодировать их иначе, чем вы при подписи, — и подпись не сойдётся, хотя адрес выглядит тем же.

Проверка выполняется там же, где проверяется API-ключ: после проверки is_active и срока действия и до обновления last_used_at. Запрос, отклонённый по подписи, не считается использованием ключа.

Требование действует на все эндпоинты, которые авторизуются обычным путём: /transcribe/, /transcribe/async, /transcriptions/*, /api-keys/*, /metrics/user, /auth/me. Сессия администратора (JWT) подписи не требует никогда — выключатель относится к ключу, а не к пользователю. OpenAI-совместимые /v1/... проверяют ключ собственным кодом и подпись не требуют: если ключ используется и там, помните, что этот путь остаётся без подписи.

Тело запроса в подпись не входит: загрузка может достигать MAX_FILE_SIZE_MB (5000 МБ), и буферизовать её ради HMAC сервис не станет. Подпись подтверждает, что запрос выпущен держателем секрета, и привязывает его к методу, адресу и моменту времени; целостность содержимого обеспечивает TLS.

Ошибки 401

Тело Когда
{ "detail": "API key requires signed requests but has no signing secret configured" } Флаг включён, а секретного слова у ключа нет (штатным путём такое состояние не создаётся)
{ "detail": "Missing signature headers (X-Signature-Timestamp, X-Signature)" } Нет одного из двух заголовков
{ "detail": "Signature timestamp outside the allowed window" } Метка устарела, пришла из будущего или это не число
{ "detail": "Invalid request signature" } Подпись не сошлась

Схема 2. Исходящий callback

Подписывается строка

<id задачи>.<timestamp>

где <id задачи> — поле id из тела вызова (оно же в заголовке X-ASR-Task-Id), а <timestamp> — значение X-Webhook-Timestamp.

Метка времени и подпись пересчитываются на каждую попытку доставки, а не один раз на задачу: иначе повтор через минуту принёс бы метку минутной давности и не прошёл бы окно свежести у приёмника. У четырёх попыток будут четыре разные пары заголовков, и проверять надо ту, что пришла с текущим запросом.

Окно свежести

Параметр По умолчанию Описание
SIGNATURE_MAX_SKEW_SEC 300 Допустимый разбег часов для X-Signature-Timestamp на входящих подписанных запросах, секунды

Запрос принимается, если |сейчас − timestamp| ≤ SIGNATURE_MAX_SKEW_SEC, то есть подпись живёт 5 минут в обе стороны — перехваченный запрос нельзя повторить назавтра. Та же метка кладётся в исходящий callback, чтобы приёмник мог применить своё окно той же ширины. Если часы убегают, лечится это синхронизацией времени (NTP), а не расширением окна.

Пример: подписанный запрос (bash + openssl)

BASE=http://localhost:8020
API_KEY=sk-XXXXXXXX...
SECRET=whsec_2Vd1kZ0oQ7pR...        # секретное слово этого ключа

REQ_PATH="/transcribe/async"
TS=$(date +%s)
SIG=$(printf '%s' "$TS.POST.$REQ_PATH" \
  | openssl dgst -sha256 -hmac "$SECRET" -binary \
  | base64)

curl -s -X POST "$BASE$REQ_PATH" \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-Signature-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -F "file=@./audio.mp3" \
  -F "language=ru"

printf '%s' здесь важен: echo добавил бы перевод строки в подписываемые данные.

Пример: подпись запроса на Python

import base64
import hashlib
import hmac
import time

import requests

BASE = "http://localhost:8020"
API_KEY = "sk-XXXXXXXX..."
SECRET = "whsec_2Vd1kZ0oQ7pR..."


def signed_headers(method: str, path_with_query: str, secret: str) -> dict:
    ts = str(int(time.time()))
    data = f"{ts}.{method.upper()}.{path_with_query}"
    digest = hmac.new(secret.encode("utf-8"), data.encode("utf-8"), hashlib.sha256).digest()
    return {
        "X-Signature-Timestamp": ts,
        "X-Signature": base64.b64encode(digest).decode("ascii"),
    }


# URL собираем сами — так подписанный путь и отправленный совпадают символ в символ
path = "/transcribe/async"
headers = {"Authorization": f"Bearer {API_KEY}"}
headers.update(signed_headers("POST", path, SECRET))

resp = requests.post(
    BASE + path,
    headers=headers,
    data={
        "url": "https://example.com/records/call.mp3",
        "callback_url": "https://example.com/hooks/asr",
        "language": "ru",
    },
    timeout=300,
)
resp.raise_for_status()
print(resp.json()["id"])

Пример: проверка callback на Python (Flask)

import base64
import hashlib
import hmac
import time

from flask import Flask, jsonify, request

SECRET = "whsec_2Vd1kZ0oQ7pR..."
MAX_SKEW_SEC = 300

app = Flask(__name__)


@app.post("/hooks/asr")
def asr_callback():
    ts = request.headers.get("X-Webhook-Timestamp", "")
    received = request.headers.get("X-Webhook-Signature", "")
    if not ts or not received:
        return jsonify(error="Missing signature headers"), 401

    try:
        if abs(int(time.time()) - int(ts)) > MAX_SKEW_SEC:
            return jsonify(error="Stale timestamp"), 401
    except ValueError:
        return jsonify(error="Bad timestamp"), 401

    task = request.get_json(silent=True) or {}
    data = f"{task.get('id', '')}.{ts}"        # id задачи из тела, он же в X-ASR-Task-Id
    expected = base64.b64encode(
        hmac.new(SECRET.encode("utf-8"), data.encode("utf-8"), hashlib.sha256).digest()
    ).decode("ascii")

    if not hmac.compare_digest(expected, received):
        return jsonify(error="Invalid signature"), 401

    # подпись верна — телу можно доверять
    print(task["id"], task["status"], len(task.get("text") or ""))
    return jsonify(status="received"), 200

Пример: проверка callback на PHP

<?php
header('Content-Type: application/json');

$secret  = getenv('ASR_WEBHOOK_SECRET');
$maxSkew = 300;

$ts  = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

if ($ts === '' || $sig === '') {
    http_response_code(401);
    exit(json_encode(['error' => 'Missing signature headers']));
}
if (abs(time() - (int) $ts) > $maxSkew) {
    http_response_code(401);
    exit(json_encode(['error' => 'Stale timestamp']));
}

$task   = json_decode(file_get_contents('php://input'), true);   // тело — JSON, не форма
$taskId = $task['id'] ?? '';                                     // он же в X-ASR-Task-Id

$expected = base64_encode(hash_hmac('sha256', $taskId . '.' . $ts, $secret, true));

if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit(json_encode(['error' => 'Invalid signature']));
}

// подпись верна — обрабатываем $task['status'] и $task['text']
http_response_code(200);
echo json_encode(['status' => 'received']);

Переменные окружения

Все новые настройки задаются в .env и имеют рабочие значения по умолчанию — менять их для включения подписи или callback не требуется.

Переменная По умолчанию Описание
FETCH_TIMEOUT_SEC 120 Абсолютный дедлайн на скачивание аудио по url, секунды
FETCH_MAX_REDIRECTS 5 Сколько редиректов разрешено пройти при скачивании (каждый проверяется политикой хостов)
FETCH_ALLOW_PRIVATE_HOSTS false Разрешить приватные/loopback/link-local адреса в url и callback_url. См. предупреждение в разделе 7: включённый, он превращает любой API-ключ в HTTP-прокси внутрь периметра
CALLBACK_TIMEOUT_SEC 30 Таймаут одной попытки доставки callback, секунды
CALLBACK_RETRIES 3 Сколько повторов после первой неудачной попытки (паузы 2 / 10 / 60 с)
CALLBACK_POLL_SECONDS 2.0 Как часто фоновый поток ищет готовые задачи с заказанным вызовом, секунды
SIGNATURE_MAX_SKEW_SEC 300 Окно свежести X-Signature-Timestamp у входящих подписанных запросов, секунды

Обработка ошибок

Все ошибки возвращаются в стандартном формате:

{
  "detail": "Описание ошибки"
}

Коды ошибок:

Не всякая неудача — это HTTP-код: файл приняли, а сломалась уже обработка. Тогда задача переходит в failed, а причина лежит в error_message (GET /transcriptions/{id}/status). Так, файл, в котором вовсе нет звуковой дорожки, даёт: «В файле нет звуковой дорожки — распознавать нечего.»

Пример обработки ошибок (Python):

import requests

def safe_api_call(url, headers, method="GET", **kwargs):
    try:
        if method == "GET":
            response = requests.get(url, headers=headers, **kwargs)
        elif method == "POST":
            response = requests.post(url, headers=headers, **kwargs)

        if response.status_code == 200:
            return response.json()
        elif response.status_code == 401:
            print("Authentication required. Please login.")
        elif response.status_code == 403:
            print("Access denied. Check permissions.")
        elif response.status_code == 404:
            print("Resource not found.")
        elif response.status_code == 429:
            print("Rate limit exceeded. Please wait.")
        else:
            error = response.json()
            print(f"Error: {error.get('detail', 'Unknown error')}")

    except requests.exceptions.RequestException as e:
        print(f"Network error: {e}")

    return None

Полный пример использования

Python - Полный workflow:

import requests
import time

class Speech2TextClient:
    def __init__(self, base_url="http://localhost:8020"):
        self.base_url = base_url
        self.token = None

    def login(self, username, password):
        """Авторизация в системе"""
        response = requests.post(
            f"{self.base_url}/auth/login",
            json={"username": username, "password": password}
        )
        if response.status_code == 200:
            self.token = response.json()["access_token"]
            print("Login successful!")
            return True
        return False

    def transcribe(self, audio_path, language=None):
        """Транскрибация аудиофайла"""
        if not self.token:
            print("Please login first")
            return None

        headers = {"Authorization": f"Bearer {self.token}"}

        with open(audio_path, "rb") as f:
            files = {"file": f}
            data = {"language": language} if language else {}

            response = requests.post(
                f"{self.base_url}/transcribe/",
                headers=headers,
                files=files,
                data=data
            )

        if response.status_code == 200:
            return response.json()
        else:
            print(f"Error: {response.text}")
            return None

    def wait_for_completion(self, transcription_id, timeout=300):
        """Ожидание завершения транскрибации"""
        headers = {"Authorization": f"Bearer {self.token}"}
        start_time = time.time()

        while time.time() - start_time < timeout:
            response = requests.get(
                f"{self.base_url}/transcriptions/{transcription_id}/status",
                headers=headers
            )

            if response.status_code == 200:
                status = response.json()
                if status["status"] == "completed":
                    return True
                elif status["status"] == "failed":
                    print(f"Transcription failed: {status.get('error_message')}")
                    return False

            time.sleep(2)

        print("Timeout waiting for transcription")
        return False

    def get_transcription(self, transcription_id):
        """Получение результата транскрибации"""
        headers = {"Authorization": f"Bearer {self.token}"}

        response = requests.get(
            f"{self.base_url}/transcribe/{transcription_id}",
            headers=headers
        )

        if response.status_code == 200:
            return response.json()
        return None

    def download_text(self, transcription_id, output_path):
        """Скачивание текста транскрибации"""
        headers = {"Authorization": f"Bearer {self.token}"}

        response = requests.get(
            f"{self.base_url}/transcriptions/{transcription_id}/download",
            headers=headers
        )

        if response.status_code == 200:
            with open(output_path, "w", encoding="utf-8") as f:
                f.write(response.text)
            print(f"Transcription saved to {output_path}")
            return True
        return False

# Использование
if __name__ == "__main__":
    client = Speech2TextClient()

    # Авторизация
    if client.login("admin", "admin123"):

        # Транскрибация
        result = client.transcribe("podcast.mp3", language="ru")
        if result:
            print(f"Transcription ID: {result['id']}")
            print(f"Status: {result['status']}")

            # Ожидание завершения
            if result['status'] == 'processing':
                if client.wait_for_completion(result['id']):
                    # Получение результата
                    full_result = client.get_transcription(result['id'])
                    print(f"Text: {full_result['text'][:200]}...")

                    # Сохранение в файл
                    client.download_text(result['id'], "transcription.txt")

JavaScript - Полный workflow:

class Speech2TextClient {
    constructor(baseUrl = 'http://localhost:8020') {
        this.baseUrl = baseUrl;
        this.token = null;
    }

    async login(username, password) {
        const response = await fetch(`${this.baseUrl}/auth/login`, {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({username, password})
        });

        if (response.ok) {
            const data = await response.json();
            this.token = data.access_token;
            console.log('Login successful!');
            return true;
        }
        return false;
    }

    async transcribe(file, language = null) {
        if (!this.token) {
            console.error('Please login first');
            return null;
        }

        const formData = new FormData();
        formData.append('file', file);
        if (language) {
            formData.append('language', language);
        }

        const response = await fetch(`${this.baseUrl}/transcribe/`, {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${this.token}`
            },
            body: formData
        });

        if (response.ok) {
            return await response.json();
        }
        return null;
    }

    async waitForCompletion(transcriptionId, timeout = 300000) {
        const startTime = Date.now();

        while (Date.now() - startTime < timeout) {
            const response = await fetch(
                `${this.baseUrl}/transcriptions/${transcriptionId}/status`,
                {
                    headers: {'Authorization': `Bearer ${this.token}`}
                }
            );

            if (response.ok) {
                const status = await response.json();
                if (status.status === 'completed') {
                    return true;
                } else if (status.status === 'failed') {
                    console.error('Transcription failed:', status.error_message);
                    return false;
                }
            }

            await new Promise(resolve => setTimeout(resolve, 2000));
        }

        console.error('Timeout waiting for transcription');
        return false;
    }

    async getTranscription(transcriptionId) {
        const response = await fetch(
            `${this.baseUrl}/transcribe/${transcriptionId}`,
            {
                headers: {'Authorization': `Bearer ${this.token}`}
            }
        );

        if (response.ok) {
            return await response.json();
        }
        return null;
    }
}

// Использование
async function main() {
    const client = new Speech2TextClient();

    // Авторизация
    if (await client.login('admin', 'admin123')) {

        // Выбор файла
        const fileInput = document.getElementById('fileInput');
        fileInput.addEventListener('change', async (e) => {
            const file = e.target.files[0];

            // Транскрибация
            const result = await client.transcribe(file, 'ru');
            if (result) {
                console.log('Transcription ID:', result.id);

                // Ожидание завершения
                if (result.status === 'processing') {
                    if (await client.waitForCompletion(result.id)) {
                        // Получение результата
                        const fullResult = await client.getTranscription(result.id);
                        console.log('Text:', fullResult.text);

                        // Отображение результата
                        document.getElementById('result').textContent = fullResult.text;
                    }
                }
            }
        });
    }
}

main();

Советы и рекомендации

Оптимизация производительности:

  1. Формат файлов: Конвертируйте в WAV 16kHz моно для быстрой обработки
  2. Размер файлов: Разбивайте большие файлы на части по 30 минут
  3. Язык: Указывайте язык явно для ускорения обработки
  4. Batch обработка: Загружайте несколько файлов параллельно
  5. Видео: .mov, .mxf, .webm, .mp4 можно слать как есть — звук извлечёт сервис. Но по сети едет весь контейнер (реальный MXF — 3.88 ГБ ради процентов полезных данных), так что на узком канале дешевле вырезать звук у себя и прислать, например, .m4a

Безопасность:

  1. Храните токены безопасно: Никогда не включайте токены в код
  2. Используйте HTTPS: В продакшене всегда используйте HTTPS
  3. Ротация ключей: Регулярно обновляйте API ключи
  4. Ограничение прав: Давайте минимально необходимые права
  5. Подпись запросов: Включите require_request_signature — украденный ключ без секретного слова станет бесполезен (раздел 8)
  6. Проверяйте подпись callback: Включите sign_callbacks и проверяйте X-Webhook-Signature на приёмнике — иначе кто угодно может прислать вам «результат»

Обработка ошибок:

  1. Retry логика: Реализуйте повторные попытки при временных ошибках
  2. Таймауты: Устанавливайте разумные таймауты для долгих операций
  3. Логирование: Логируйте все ошибки для отладки
  4. Graceful degradation: Предусмотрите fallback сценарии

Полезные ссылки


Поддержка

При возникновении проблем: 1. Проверьте логи сервера 2. Убедитесь в правильности токена 3. Проверьте формат и размер файла 4. Обратитесь к документации API

Для технической поддержки создайте issue на GitHub или напишите на support@speech2text.com