Skip to content

Integration API v1

Практическая инструкция с примерами запросов: кнопка видеовстречи в CRM и сохранение расшифровки.

Integration API предназначен для backend-to-backend сценариев: внешняя платформа создает комнату по своему external_id, получает ссылку на hosted VideoGrace room и показывает ее пользователю в личном кабинете. На первом этапе внешний сервис не встраивает видеоклиент и не синхронизирует пользователей.

Готовность MVP

Текущий MVP уже можно использовать для пилотной интеграции, если сценарий выглядит так:

  • backend партнера создает или получает комнату по external_id урока;
  • VideoGrace возвращает hosted-ссылки для преподавателя и ученика;
  • партнер показывает ссылку пользователю в своем личном кабинете;
  • пользователь открывает VideoGrace web room в браузере;
  • партнер проверяет состояние комнаты polling'ом.

Что входит в v1:

  • service-account авторизация backend-to-backend;
  • server_info для проверки доступности API;
  • идемпотентное создание комнаты по external_id;
  • получение обычной ссылки на комнату;
  • отдельные signed join links для ролей teacher и student;
  • обновление названия и времени;
  • отмена, закрытие и status polling;
  • подписка на webhook recording.ready после завершения загрузки записи;
  • подписка на webhook transcription.ready с итогом живой транскрипции одного CAN job;
  • самодостаточный smoke-тест без внешних Python-зависимостей.

Что не входит в v1:

  • встраивание VideoGrace клиента в iframe или SDK;
  • user provisioning партнерских пользователей;
  • webhooks room.started, participant.joined, participant.left, room.ended;
  • управление правами отдельных участников после входа;
  • end-to-end браузерный тест открытия /j/<token> из smoke-теста.

Архив завершенных recording jobs доступен через отдельный Records API. Для связи урока и записи партнер должен сохранить tag, возвращенный Rooms API: в архиве этот же идентификатор находится в поле conference_tag. Запуск recorder и консолидация дорожек не входят в контракт Rooms API.

Базовый путь:

https://<server>/api/v1/integration

Включение модуля

Integration API является опциональным серверным модулем и по умолчанию отключен. Его данные не хранятся в main.db.

[Db]
IntegrationDb = /opt/VideoGrace/Server/db/integration.db

[Integration]
Enabled = 1
PublicUrl = https://join.example.com

Те же параметры можно задать переменными окружения VG_INTEGRATION_ENABLED=1, VG_INTEGRATION_DB=/path/integration.db и VG_SERVER_URL=https://join.example.com. Настройки применяются после перезапуска сервера.

При Enabled = 0 сервер не открывает и не создает integration.db, endpoint-ы /api/v1/integration/* возвращают HTTP 404, а signed partner policy, webinar layout и автоматическая запись не применяются к обычным конференциям.

При первом включении создается чистая БД с таблицами service_accounts, rooms, participants, webhook_subscriptions и webhook_deliveries. Автоматического импорта старой таблицы integration_rooms из main.db нет.

Отключенный режим можно проверить без service account:

python3 tests/integration/vg_integration_api_smoke.py \
  --base-url https://join.example.com \
  --expect-disabled

Типовые сценарии

Сценарий 1. Создание урока в LMS

Партнерская платформа создает урок у себя и вызывает:

PUT /api/v1/integration/rooms/partner_lesson_987
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "title": "Урок фортепиано",
  "starts_at": "2026-07-10T15:00:00+07:00",
  "ends_at": "2026-07-10T15:45:00+07:00"
}

VideoGrace создает конференцию и возвращает:

  • join_url - общая ссылка на комнату;
  • teacher_join_url - ссылка преподавателя;
  • student_join_url - ссылка ученика.

Партнер сохраняет room_id, tag и ссылки у себя рядом с уроком.

Сценарий 2. Повторная отправка после сетевого сбоя

Если PUT /rooms/{external_id} был отправлен, но ответ не был получен из-за таймаута, партнер должен повторить тот же PUT с тем же external_id.

Метод идемпотентный:

  • если комнаты еще нет, она будет создана;
  • если комната уже есть, сервер вернет существующую комнату;
  • повторный PUT не перезаписывает title, starts_at, ends_at.

Для изменения уже созданной комнаты используйте PATCH.

Сценарий 3. Перенос урока

Когда урок перенесли в LMS:

PATCH /api/v1/integration/rooms/partner_lesson_987
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "title": "Урок фортепиано: новое время",
  "starts_at": "2026-07-10T16:00:00+07:00",
  "ends_at": "2026-07-10T16:45:00+07:00"
}

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

Сценарий 4. Отмена урока

Если урок отменен до начала:

POST /api/v1/integration/rooms/partner_lesson_987/cancel
Authorization: Bearer <access_token>

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

Сценарий 5. Завершение урока

Если платформа принудительно завершает урок:

POST /api/v1/integration/rooms/partner_lesson_987/close
Authorization: Bearer <access_token>

Room получает статус closed. Если конференция активна, сервер пытается завершить ее.

Сценарий 6. Проверка состояния перед показом кнопки "Войти"

Партнер может регулярно выполнять:

GET /api/v1/integration/rooms/partner_lesson_987/status
Authorization: Bearer <access_token>

Рекомендуемая логика:

  • status=scheduled - показать кнопку "Войти" или "Ожидает начала";
  • status=active и live=true - урок идет;
  • status=cancelled - не показывать ссылку входа;
  • status=closed - показать "урок завершен".

Поток подключения

sequenceDiagram
    participant LMS as Partner backend
    participant VG as VideoGrace Integration API
    participant T as Teacher browser
    participant S as Student browser

    LMS->>VG: POST /auth/token
    VG-->>LMS: access_token
    LMS->>VG: PUT /rooms/{external_id}
    VG-->>LMS: room_id, tag, teacher_join_url, student_join_url
    LMS-->>T: показать teacher_join_url
    LMS-->>S: показать student_join_url
    T->>VG: открыть /j/<teacher_token>
    S->>VG: открыть /j/<student_token>
    T->>VG: POST /integration/join/<teacher_token>
    VG-->>T: токены постоянного аккаунта по teacher.external_id
    S->>VG: POST /integration/join/<student_token>
    VG-->>S: токены постоянного аккаунта по student.external_id
    LMS->>VG: GET /rooms/{external_id}/status
    VG-->>LMS: scheduled | active | cancelled | closed

Авторизация

Внешний backend получает короткий bearer token через service account:

POST /api/v1/integration/auth/token
Content-Type: application/json

{
  "service_account": "partner",
  "client_secret": "..."
}

Ответ:

{
  "result": 200,
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 604800,
  "refresh_token": null
}

refresh_token в v1 не выдается. Для нового access token backend повторяет auth/token.

Все остальные методы вызываются с заголовком:

Authorization: Bearer <access_token>

Настройка service account

Service account привязывается к обычному пользователю VideoGrace. Этот пользователь становится владельцем созданных интеграцией конференций.

Для партнера создавайте отдельного технического пользователя, а не привязывайте service account к администратору. Так проще ограничить права, аудит и последующую ротацию секрета.

Сначала создайте технического пользователя в основной БД:

insert into clients
  (name, login, password, type, number, time_limit, grants, creation_time, guid)
values
  ('Partner Service', 'partner_service', '', 1, 900001, 0, 0, strftime('%s','now'), lower(hex(randomblob(16))));

После запуска сервера с Integration.Enabled = 1 добавьте партнерский account уже в отдельную integration.db. Для тестового стенда можно использовать plain-secret:

insert into service_accounts
  (name, client_id, secret_hash, scopes, enabled, created_at)
values
  ('partner', 228800123, 'plain:dev-secret', '["integration:rooms","blob"]', 1, strftime('%s','now'))
on conflict(name) do update set
  client_id = excluded.client_id,
  secret_hash = excluded.secret_hash,
  scopes = excluded.scopes,
  enabled = 1;

Здесь 228800123 - clients.id созданного технического пользователя из main.db. Например:

CLIENT_ID="$(sqlite3 /opt/VideoGrace/Server/db/main.db \
  "select id from clients where login='partner_service'")"

sqlite3 /opt/VideoGrace/Server/db/integration.db \
  "insert into service_accounts(name, client_id, secret_hash, scopes, enabled, created_at)
   values('partner', $CLIENT_ID, 'plain:dev-secret', '[\"integration:rooms\",\"blob\"]', 1, strftime('%s','now'))
   on conflict(name) do update set client_id=excluded.client_id,
     secret_hash=excluded.secret_hash, scopes=excluded.scopes, enabled=1"

Для production храните не сам секрет, а HMAC от него:

secret_hash = hmac-sha256:<base64url(hmac_sha256(VG_JWT_SECRET, client_secret))>

Хеш можно посчитать поставочным smoke-тестом:

VG_JWT_SECRET='server-jwt-secret' \
VG_INTEGRATION_CLIENT_SECRET='partner-client-secret' \
python3 tests/integration/vg_integration_api_smoke.py --print-secret-hash

После создания нового пользователя нужен рестарт vgserver, потому список пользователей загружается сервером в память. Изменение записи partner account в integration.db рестарта не требует.

Проверка API

GET /api/v1/integration/server_info
Authorization: Bearer <access_token>

Ответ содержит версию и advertised features:

{
  "result": 200,
  "api": {
    "version": "v1",
    "base_path": "/api/v1/integration",
    "features": [
      "rooms.idempotent_put",
      "rooms.status_polling",
      "signed_join_links",
      "signed_join_links.resolve",
      "signed_join_links.display_name",
      "signed_join_links.teacher_moderator",
      "rooms.partner_videoroom",
      "rooms.automatic_recording",
      "webhooks.recording_ready",
      "webhooks.transcription_ready"
    ]
  },
  "server": {}
}

Webhook готовности записи и расшифровки

Webhook настраивается один раз для service account и применяется ко всем созданным этим техническим пользователем партнерским комнатам. На каждую готовую запись VideoGrace отправляет событие recording.ready только после того, как recorder завершил upload всех артефактов и сервер успешно сохранил запись в records.db.

transcription.ready передает текст, который транскрибер распознавал во время разговора. Он отправляется после завершения задания транскрибера, независимо от записи и ее загрузки. Повторное распознавание MP4 не требуется.

Настройка

PUT /api/v1/integration/webhook
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "url": "https://partner.example.com/webhooks/videograce",
  "secret": "replace-with-at-least-16-random-characters",
  "events": ["recording.ready"],
  "enabled": true
}

Требования:

  • URL должен использовать публичный https:// endpoint;
  • redirect не обрабатывается, endpoint должен сразу вернуть 2xx;
  • secret должен содержать от 16 до 256 символов;
  • при обновлении настройки существующие url, secret и events можно не передавать;
  • enabled=false отключает новые доставки и отменяет ожидающие retry;
  • поддерживаются recording.ready и transcription.ready; без явного списка новая подписка включает только recording.ready;
  • отправляются только выбранные события; удаление типа из подписки отменяет его ожидающие доставки при следующей обработке очереди.

Например, временно отключить уже настроенный webhook можно без повторной передачи секрета:

PUT /api/v1/integration/webhook
Authorization: Bearer <access_token>
Content-Type: application/json

{"enabled": false}

Ответ не возвращает секрет:

{
  "result": 200,
  "configured": true,
  "url": "https://partner.example.com/webhooks/videograce",
  "events": ["recording.ready"],
  "enabled": true,
  "secret_configured": true
}

Прочитать текущую настройку:

GET /api/v1/integration/webhook
Authorization: Bearer <access_token>

Payload transcription.ready

Для получения текста включите "events": ["transcription.ready"], либо оба типа: "events": ["recording.ready", "transcription.ready"]. Новый сервер сообщает поддержку через feature webhooks.transcription_ready в server_info. Обновить нужно также worker: старый транскрибер не отправляет финальный CAN artifact.

{
  "id": "wh_example_transcription",
  "type": "transcription.ready",
  "created_at": "2026-09-13T10:30:05Z",
  "data": {
    "room": {"external_id": "crm_meeting_987", "tag": "lesson-a8f3k2"},
    "transcription": {
      "id": "transcriber-job-001",
      "job_id": "transcriber-job-001",
      "conference_tag": "lesson-a8f3k2",
      "conference_session_id": 1842,
      "status": "completed",
      "language": "ru",
      "started_at_ms": 1789293600000,
      "ended_at_ms": 1789295400000,
      "reason": "empty_conference",
      "text": "Обсудим предложение.\nДоговорились о пилоте.",
      "segments": [
        {"seq": 1, "at": 1789293605000, "speaker": {"client_id": 101, "device_id": 1000, "name": "Иван"}, "text": "Обсудим предложение.", "partial": false},
        {"seq": 2, "at": 1789295395000, "speaker": {"client_id": 102, "device_id": 1001, "name": "Мария"}, "text": "Договорились о пилоте.", "partial": false}
      ]
    }
  }
}
  • Это итог одного запуска транскрибера, а не событие room.ended. При остановке и повторном включении в одном созвоне будут разные job_id при одинаковом conference_session_id.
  • text содержит тексты сегментов, разделенные переводом строки; имена есть в segments[].speaker. at обозначает время публикации распознанного фрагмента, а не точный таймкод слова в аудиозаписи.
  • started_at_ms / ended_at_ms описывают работу задания, включая завершение распознавания; они не заменяют статистику посещения встречи.
  • Пустой результат допустим: text: "", segments: [], если речь не распознана.
  • Сервер принимает итог только от назначенного CAN-воркера; job_id, комната и ID созвона сверяются с заданием. Доставка идет владельцу партнерской комнаты.
  • Подпись и повторы такие же, как для recording.ready. Дедупликация доставки: event.id; хранения текста: сервер + conference_session_id + job_id.
  • Создание комнаты не запускает транскрибер автоматически. Запуск через CAN настраивается отдельно: Transcriber.
  • Очередь webhook сохраняется в БД после приема финального события сервером. Аварийное завершение worker до отправки итога не считается успешной транскрипцией; отдельного API архива транскриптов пока нет.

Payload recording.ready

{
  "id": "wh_52b39d1d41a540cea7a1d10d2830e694",
  "type": "recording.ready",
  "created_at": "2026-08-05T10:15:42Z",
  "data": {
    "room": {
      "external_id": "partner_lesson_987",
      "tag": "lesson-a8f3k2"
    },
    "recording": {
      "id": "19fd13d97fd0",
      "job_id": "19fd13d97fd0",
      "conference_tag": "lesson-a8f3k2",
      "conference_session_id": 1842,
      "profile": "raw_tracks_v1",
      "status": "stopped",
      "started_at_ms": 1785924900000,
      "ended_at_ms": 1785927600000,
      "duration_ms": 2700000,
      "report_blob_id": "7bd85ee0-...",
      "report_url": "https://join.example.com/api/storage/blobs/7bd85ee0-...",
      "manifest_blob_id": "42d62bd0-...",
      "manifest_url": "https://join.example.com/api/storage/blobs/42d62bd0-...",
      "files": []
    }
  }
}

data.room.external_id позволяет связать событие с уроком без отдельного поиска по conference_tag. data.recording.conference_session_id однозначно связывает запись с конкретным запуском комнаты, статистикой и расшифровкой. Состав files и наличие отдельных report URL зависят от профиля recorder. Дополнительные поля могут добавляться без смены версии события.

Blob URL приватны. Для скачивания файла backend партнера должен получить актуальный Integration API access token и передать его в запросе:

GET /api/storage/blobs/{blob_id}
Authorization: Bearer <access_token>

Service account получает доступ только к артефактам комнат, владельцем которых он является. Выдавать техническому пользователю глобальный grant recordings.access для этого не требуется.

Для интеграции с выгрузкой записей у аккаунта в integration.db.service_accounts нужны оба scope: integration:rooms для комнат и blob для скачивания. blob не отменяет ACL: чужие приватные записи остаются недоступными. Один integration:rooms позволяет получать webhook, но скачивание вернет 403 как с Bearer, так и с параметром t.

После добавления blob в существующий список scopes получите новый token через POST /api/v1/integration/auth/token: уже выданный JWT сохраняет старые scopes до истечения срока. Перезапуск vgserver и повторная запись урока не нужны; URL из ранее полученных webhook также работают с новым token. Предпочитайте заголовок Bearer: token в URL может попасть в историю и access-логи.

Открытие такого URL непосредственно в адресной строке браузера без заголовка Authorization ожидаемо вернет HTTP 401.

Подпись

Каждая доставка содержит заголовки:

X-VideoGrace-Event: recording.ready
X-VideoGrace-Delivery: wh_52b39d1d41a540cea7a1d10d2830e694
X-VideoGrace-Timestamp: 1785924942
X-VideoGrace-Signature: sha256=<hex-hmac>

Подписывается точная последовательность байт:

<X-VideoGrace-Timestamp>.<raw HTTP body>

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

import hashlib
import hmac
import time

def verify(headers, raw_body: bytes, secret: str) -> bool:
    timestamp = headers["X-VideoGrace-Timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        return False
    signed = timestamp.encode("ascii") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    supplied = headers["X-VideoGrace-Signature"].removeprefix("sha256=")
    return hmac.compare_digest(expected, supplied)

Проверять подпись нужно до JSON parsing. Рекомендуется также отклонять timestamp старше пяти минут.

Повторы и идемпотентность

Получатель должен вернуть любой HTTP 2xx только после надежного сохранения события. При сетевой ошибке, 408, 425, 429 или 5xx VideoGrace выполняет до шести попыток с задержками 10, 30, 120, 600 и 1800 секунд. Остальные ответы 4xx считаются окончательной ошибкой.

Повторные попытки используют одинаковый id и X-VideoGrace-Delivery, поэтому получатель должен делать upsert или insert-if-absent по этому идентификатору. Очередь хранится в integration.db и переживает рестарт VideoGrace Server.

Smoke-тест настройки webhook:

python3 tests/integration/vg_integration_api_smoke.py \
  --base-url https://join.example.com \
  --service-account partner \
  --client-secret "$CLIENT_SECRET" \
  --webhook-url https://partner.example.com/webhooks/videograce \
  --webhook-secret "$WEBHOOK_SECRET"

Комнаты

external_id задает внешний идентификатор урока. Он должен быть непустым и не длиннее 255 символов.

Рекомендуемый формат:

<partner>_<entity>_<id>
partner_lesson_987

external_id должен быть стабильным. Не используйте в нем случайный UUID на каждый retry, иначе идемпотентность потеряется.

Idempotent create/get

PUT /api/v1/integration/rooms/partner_lesson_987
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "title": "Урок фортепиано",
  "starts_at": "2026-07-10T15:00:00+07:00",
  "ends_at": "2026-07-10T15:45:00+07:00",
  "description": "Индивидуальный урок",
  "teacher": {
    "display_name": "Иван Петров"
  },
  "student": {
    "display_name": "Мария Иванова"
  }
}

Если комнаты еще нет, сервер создает конференцию и возвращает ссылки. Если external_id уже существует, сервер возвращает существующую комнату и не перезаписывает ее поля.

{
  "room_id": 123,
  "external_id": "partner_lesson_987",
  "tag": "lesson-a8f3k2",
  "title": "Урок фортепиано",
  "starts_at": "2026-07-10T15:00:00+07:00",
  "ends_at": "2026-07-10T15:45:00+07:00",
  "status": "scheduled",
  "join_url": "https://video.example.com/conferences/lesson-a8f3k2",
  "teacher_join_url": "https://video.example.com/j/...",
  "student_join_url": "https://video.example.com/j/...",
  "teacher": {
    "display_name": "Иван Петров"
  },
  "student": {
    "display_name": "Мария Иванова"
  }
}

Получение существующей комнаты:

GET /api/v1/integration/rooms/partner_lesson_987
Authorization: Bearer <access_token>

Практический контракт PUT:

  • external_id - обязательный path parameter;
  • title или name - человекочитаемое название комнаты;
  • starts_at и ends_at - ISO 8601 строка с timezone;
  • description - опционально;
  • teacher.display_name - опциональное имя преподавателя для hosted join page;
  • student.display_name - опциональное имя ученика для hosted join page;
  • teacher.external_id, student.external_id - стабильные ID людей в системе партнёра; строка от 1 до 255 байт. Передавайте их для повторного использования аккаунта между уроками и устройствами;
  • ответ всегда содержит актуальные ссылки.

Постоянные аккаунты участников

При создании комнаты передавайте идентификаторы людей, не идентификаторы урока:

{
  "title": "Урок гитары",
  "teacher": {"external_id": "teacher:42", "display_name": "Иван Петров"},
  "student": {"external_id": "student:108", "display_name": "Мария Иванова"}
}

Ключ идентичности: технический пользователь партнёра (service_accounts.client_id) + внешний ID человека. ID урока, имя, роль и браузер в этот ключ не входят. Один преподаватель получает один аккаунт во всех уроках партнёра; совпадающие имена разных людей не объединяются. Если ID в таблицах преподавателей и учеников могут совпадать, используйте разные пространства имён (teacher:42, student:42). Если у вас единый ID человека для обеих ролей, передавайте его неизменно.

При participant_auth: true в ответе resolver web-клиент вызывает:

POST /api/v1/integration/join/<personal_token>

Сервер находит или создаёт обычный пользовательский аккаунт и возвращает result: 200, login, user_id, access_token, refresh_token, token_type: "Bearer". Это не service token: права модератора по-прежнему назначаются при входе в конкретный урок по преподавательской ссылке. Refresh token сохраняется клиентом до подключения WSS. Повтор запроса, новая вкладка, другое устройство или очищенное browser storage не создают нового человека. Привязка хранит client_id, поэтому смена логина аккаунта не теряет связь.

GET выдаёт только данные приглашения, не токены авторизации. Персональная ссылка теперь является ключом входа в аккаунт человека: отдавайте её только этому пользователю через авторизованный кабинет LMS, не публикуйте и не используйте одну преподавательскую ссылку для нескольких людей. Закрытые и отменённые уроки не выдают новые токены (403); уже выданные сессии живут по обычным правилам JWT. При изменении teacher.external_id или student.external_id через PATCH ссылка соответствующей роли ротируется; старый URL перестаёт работать. Партнёр обязан использовать новые URL из ответа.

Для уже созданных уроков добавьте ID через PATCH /rooms/{external_id}: повторный PUT намеренно не меняет существующую комнату. После добавления ID также используйте обновлённые ссылки. Миграция схемы integration.db выполняется сервером при запуске и не удаляет прежние данные. Старые дубли не объединяются по имени и не удаляются автоматически.

Без внешних ID остаётся гостевой режим: в одном браузерном профиле используется сохранённая гостевая сессия, независимо от ссылки и вкладки. Сбой подключения не удаляет её и не разрешает новую регистрацию; переход к следующей сессии допускается только после явного ответа об удалённом аккаунте. Для браузеров с Web Locks одновременная авторегистрация во вкладках сериализована. Без ID нельзя гарантировать единую личность между устройствами, после удаления browser storage или в приватном режиме: для этого партнёр должен передавать ID людей.

Проверка на изолированном сервере: python3 tests/integration/vg_participant_identity_smoke.py с переменными VG_INTEGRATION_BASE_URL, VG_INTEGRATION_SERVICE_ACCOUNT, VG_INTEGRATION_CLIENT_SECRET. Для проверки изоляции второго партнёра задайте VG_OTHER_SERVICE_ACCOUNT и VG_OTHER_CLIENT_SECRET. Тест создаёт пользователей и комнаты, проверяет параллельные входы, смену роли, ротацию ссылок, refresh и закрывает тестовые комнаты. Пользователей не удаляет.

Signed join resolver

Hosted web-client открывает teacher_join_url и student_join_url как обычные страницы вида /j/<token>. Под капотом клиент вызывает публичный resolver:

GET /api/v1/integration/join/<token>

Bearer token здесь не нужен: сам <token> является секретом signed link. Ответ не содержит teacher_join_url и student_join_url, чтобы публичный resolver не раскрывал вторую роль.

{
  "result": 200,
  "role": "teacher",
  "participant_auth": true,
  "display_name": "Иван Петров",
  "room_id": 123,
  "external_id": "partner_lesson_987",
  "tag": "lesson-a8f3k2",
  "title": "Урок фортепиано",
  "starts_at": "2026-07-10T15:00:00+07:00",
  "ends_at": "2026-07-10T15:45:00+07:00",
  "status": "scheduled",
  "join_url": "https://video.example.com/conferences/lesson-a8f3k2",
  "ui_profile": "partner_videoroom",
  "layout_mode": "webinar",
  "features": {
    "main_navigation": false,
    "chat": true,
    "transcription": false,
    "recording_controls": false
  },
  "recording": {
    "mode": "automatic"
  }
}

partner_videoroom является фиксированным hosted-профилем интеграции. Преподавателю и ученику доступны видеокомната, управление медиа, чат комнаты и кнопка полноэкранного режима рядом с выбором раскладки. Чат использует обычные сообщения конференции и сохраняет ограничения модерации, включая режим «только чтение». Главное меню VideoGrace, отдельная панель расшифровки, управление транскрибером и ручные кнопки старта/остановки записи скрыты. Комната создается с раскладкой webinar по умолчанию. Если подключены только преподаватель и ученик, VAD продолжает подсвечивать говорящего, но не меняет плитки местами; динамический выбор докладчиков начинается с трех участников.

Полноэкранный режим сохраняет видеоэлементы и открытый чат без переподключения медиа. Если браузер не предоставляет Fullscreen API, комната разворачивается в пределах текущего окна; системные панели браузера при этом остаются. Для встроенной комнаты требуется разрешение fullscreen от родительского iframe, как в примере ниже.

Signed token передается web-клиентом в control-команде входа и проверяется сервером для конкретного tag. Роль нельзя повысить изменением query string или состояния браузера:

  • teacher_join_url выдает преподавателю серверный grant Moderator;
  • student_join_url подключает обычного участника;
  • токен одной комнаты не действует в другой комнате;
  • после F5 и control WebSocket reconnect токен хранится только в sessionStorage текущей вкладки и повторно передается серверу.

Автоматическая запись

При первом подключении участника к партнерской комнате сервер идемпотентно ставит CAN-задачу recording.start с профилем scope=partner_auto. Запись включает звук, видео и демонстрацию экрана и загружается в server storage. Одновременный вход преподавателя и ученика не создает две активные задачи записи для одной комнаты.

Recorder worker должен быть запущен и зарегистрирован с ролью recorder и capability recording. Если worker недоступен или service account recorder не настроен, вход пользователей не блокируется, а причина отказа запуска записи фиксируется в серверном логе. В hosted UI ручное управление записью намеренно отсутствует; активная запись обозначается индикатором REC.

Update

PATCH обновляет изменяемые параметры комнаты:

PATCH /api/v1/integration/rooms/partner_lesson_987
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "title": "Урок фортепиано: перенос времени",
  "starts_at": "2026-07-10T16:00:00+07:00",
  "ends_at": "2026-07-10T16:45:00+07:00",
  "teacher": {
    "display_name": "Иван Петров"
  },
  "student": {
    "display_name": "Мария Иванова"
  }
}

PATCH можно вызывать с частичным body. Поля, которых нет в body, остаются без изменений.

Cancel и close

Отмена деактивирует конференцию и переводит room в cancelled:

POST /api/v1/integration/rooms/partner_lesson_987/cancel
Authorization: Bearer <access_token>

Закрытие завершает активную конференцию и переводит room в closed:

POST /api/v1/integration/rooms/partner_lesson_987/close
Authorization: Bearer <access_token>

Status polling

GET /api/v1/integration/rooms/partner_lesson_987/status
Authorization: Bearer <access_token>

Ответ совпадает с room response и дополнительно содержит:

{
  "status": "active",
  "stored_status": "scheduled",
  "live": true
}

status=active вычисляется динамически, когда конференция сейчас активна, а сохраненный статус еще scheduled.

Ошибки и retry policy

Ответы API возвращаются как JSON-объект. При ошибке обычно используются поля:

{
  "result": 401,
  "message": "Unauthorized"
}

Рекомендуемая обработка:

HTTP/result Причина Что делать
400 неверный JSON, пустой external_id, ошибка сохранения исправить payload, retry тем же body не поможет
401 неверный bearer token или client secret запросить новый token; если не помогло, проверить service account
404 room по external_id не найден создать через PUT или проверить внешний id
5xx временная серверная ошибка повторить с backoff; для create использовать тот же external_id
network timeout неизвестно, выполнен ли запрос для PUT повторить тот же external_id; для PATCH/cancel/close можно безопасно повторить

Рекомендуемый backoff для backend-to-backend вызовов:

  • 1 секунда;
  • 3 секунды;
  • 10 секунд;
  • далее ручная диагностика или фоновая очередь retry.

Не делайте бесконечный tight loop. Все операции должны логироваться у партнера с external_id, HTTP status и room_id/tag, если они уже известны.

Роли и ссылки

API возвращает три ссылки:

  • join_url - обычная ссылка на комнату.
  • teacher_join_url - signed join link преподавателя.
  • student_join_url - signed join link ученика.

Модель signed join links выбрана для MVP, чтобы партнёрская система не синхронизировала пользователей на первом этапе. Расширение с user provisioning можно добавить отдельными методами позже.

Преподаватель по teacher_join_url является модератором только в рамках текущего подключения по подписанному токену. Модераторская роль не записывается как постоянное право технического гостевого аккаунта.

Backend API выдает токены и ссылки как часть контракта. End-to-end вход по https://<server>/j/<token> поддерживается hosted web-client через публичный resolver /api/v1/integration/join/<token>. Smoke-тест ниже проверяет выдачу ссылок и resolver, но не открывает браузер и не подтверждает пользовательский prejoin.

Если в room передан teacher.display_name или student.display_name, resolver возвращает это имя в поле display_name, а hosted web-client подставляет его в prejoin форму. Пользователь может изменить имя перед входом. Имя не является ключом идентичности: для постоянного аккаунта передавайте external_id человека, как описано выше.

Iframe embedding

/j/<token> технически является hosted web-client страницей и может открываться в iframe, если deployment разрешает встраивание через CSP. Для production это нужно явно настроить на стороне VideoGrace/proxy:

Content-Security-Policy: frame-ancestors 'self' https://<partner-domain>

Минимальный iframe:

<iframe
  src="https://<server>/j/<token>"
  allow="camera; microphone; autoplay; fullscreen; display-capture"
  allowfullscreen
></iframe>

Ограничения v1:

  • если CSP frame-ancestors не разрешает домен партнера, браузер заблокирует iframe;
  • X-Frame-Options: DENY/SAMEORIGIN нельзя использовать вместе с cross-origin embedding;
  • для камеры, микрофона и демонстрации нужны allow permissions на iframe;
  • браузерные политики autoplay и permissions могут отличаться между Chrome/Safari/Firefox, поэтому embedded режим требует отдельного приемочного теста у партнера;
  • display name передается через signed link metadata, а не query string.

Рекомендуемая логика в личном кабинете:

  • преподавателю показывать только teacher_join_url;
  • ученику показывать только student_join_url;
  • join_url использовать как fallback или админскую ссылку;
  • не отправлять signed links в публичные логи и analytics;
  • при компрометации ссылки закрыть комнату и создать новую с новым external_id или будущим endpoint ротации ссылок.

Минимальный пример backend-клиента

BASE_URL="https://video.example.com"
SERVICE_ACCOUNT="partner"
CLIENT_SECRET="dev-secret"
EXTERNAL_ID="partner_lesson_987"

TOKEN="$(
  curl -sS "$BASE_URL/api/v1/integration/auth/token" \
    -H 'Content-Type: application/json' \
    -d "{\"service_account\":\"$SERVICE_ACCOUNT\",\"client_secret\":\"$CLIENT_SECRET\"}" |
  python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'
)"

curl -sS -X PUT "$BASE_URL/api/v1/integration/rooms/$EXTERNAL_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Урок фортепиано",
    "starts_at": "2026-07-10T15:00:00+07:00",
    "ends_at": "2026-07-10T15:45:00+07:00"
  }'

Интеграционный smoke-тест

В репозитории есть самодостаточный тест без внешних Python-зависимостей:

VG_INTEGRATION_BASE_URL=https://video.example.com \
VG_INTEGRATION_SERVICE_ACCOUNT=partner \
VG_INTEGRATION_CLIENT_SECRET='partner-client-secret' \
python3 tests/integration/vg_integration_api_smoke.py

Для локального сервера с self-signed TLS:

VG_INTEGRATION_BASE_URL=https://localhost:9443 \
VG_INTEGRATION_SERVICE_ACCOUNT=partner \
VG_INTEGRATION_CLIENT_SECRET='dev-secret' \
VG_INTEGRATION_INSECURE_TLS=1 \
python3 tests/integration/vg_integration_api_smoke.py

Сценарий проверяет:

  • выпуск service bearer token;
  • server_info и advertised features;
  • создание комнаты через PUT;
  • идемпотентность повторного PUT;
  • чтение комнаты через GET;
  • обновление через PATCH;
  • polling статуса;
  • cancel;
  • close;
  • финальный статус closed.

Тест создает комнату с external_id=vg-smoke-<utc timestamp> и в конце закрывает ее. Для повторяемого прогона задайте свой VG_INTEGRATION_EXTERNAL_ID.

Пример успешного прогона:

PASS auth/token
PASS server_info
PASS rooms PUT create external_id=vg-smoke-20260715T174001Z tag=lesson-cbcac7b3
PASS rooms PUT idempotent
PASS rooms GET
PASS rooms PATCH
PASS rooms status status=scheduled live=False
PASS rooms cancel
PASS rooms close
PASS rooms final status
PASS integration API smoke test