Expo Updates Protocol v1

Otapush говорит на Expo Updates Protocol v1 — том же сетевом протоколе, который уже реализован в стандартной клиентской библиотеке expo-updates. Никакого Otapush SDK, никакого форка, никакого своего нативного модуля: вы указываете updates.url на сервер Otapush, и дальше всё делает клиент, который пришёл вместе с вашим Expo SDK.

Эта страница описывает протокол таким, каким его реализует Otapush, — включая места, недоописанные в спецификации, и обходные пути, которые обнаруживаются только на реальных устройствах. Всё, что ниже, сверено с исходниками сервера и с expo-updates 0.26.19; там, где Otapush отступает от спецификации или чего-то не реализует, об этом сказано прямо.

Нормативный источник — Expo Updates v1. Он короткий и сознательно оставляет часть вопросов открытыми.

Что обязан вернуть соответствующий спецификации сервер

Соответствующий сервер выставляет ровно одну точку, которую опрашивает клиент, плюс сколько угодно URL для скачивания ассетов. У Otapush их две:

GET /api/updates/:slug/manifest
GET /api/updates/:slug/assets?asset=<key>&runtimeVersion=<version>

:slug — слаг приложения из портала, то самое значение, которое otapush init записывает в app.json. Обе точки публичные: ни сессии портала, ни API-ключа. API-ключ защищает публикацию, а не раздачу.

На запрос манифеста сервер обязан ответить ровно одним из трёх:

  1. Манифестом — есть обновление, которое клиенту следует скачать и запустить.
  2. Директивой — обновления нет, но клиенту всё равно нужна инструкция (noUpdateAvailable или rollBackToEmbedded).
  3. Ошибкой — запрос некорректен либо приложение/канал не существует.

Всё остальное — как выбирается обновление, что такое канал, как устроен откат — за рамками протокола. Протокол фиксирует только форму ответа.

Как заголовок expo-protocol-version выбирает форму ответа

Клиент присылает expo-protocol-version. Otapush принимает 0 и 1; всё остальное — 400:

{ "error": "Unsupported protocol version. Expected either 0 or 1." }

Отсутствующий заголовок трактуется как 0. Это важно, потому что версии 0 и 1 по-разному говорят «делать нечего»:

Ситуация Протокол 0 Протокол 1
Есть обновление 200, multipart с частью manifest 200, multipart с частью manifest
Клиент уже на свежайшем обновлении 404 с JSON-телом ошибки 200, multipart с директивой noUpdateAvailable
Для этой платформы/рантайма обновлений нет 404 с JSON-телом ошибки 200, multipart с директивой noUpdateAvailable
Канал откачен за первое обновление 200 + rollBackToEmbedded 200 + rollBackToEmbedded

Это самое запутанное место протокола, если писать сервер самому. В версии 0 «нет обновления» — это HTTP-ошибка; в версии 1 — успешный ответ, дурную весть несёт тело. Сервер, который отвечает 404 клиенту версии 1, оставляет его с необъяснённым сетевым сбоем вместо чистого «ты актуален», и поведение повторных попыток у клиента меняется соответственно.

Ответ всегда возвращает согласованную версию:

expo-protocol-version: 1
expo-sfv-version: 0
cache-control: private, max-age=0
content-type: multipart/mixed; boundary=----ota-boundary-<uuid>

Одно отступление, о котором стоит знать: Otapush всегда отвечает multipart/mixed, в том числе для протокола 0. Спецификация допускает голое тело application/expo+json, и старые сторонние серверы им пользуются; Otapush — нет. Клиент expo-updates разбирает multipart независимо от запрошенной версии протокола, поэтому на практике это ни разу не помешало, — но если вы тестируете самописным клиентом, ждите multipart.

Что клиент присылает в каждом запросе манифеста

Заголовок Пример Смысл
expo-protocol-version 1 Версия протокола. Отсутствие означает 0
expo-platform ios / android Платформа клиента. Обязателен
expo-runtime-version 1.0.0 Версия рантайма бинарника. Обязателен, сверяется точно
expo-current-update-id UUID Обновление, которое клиент выполняет прямо сейчас
expo-embedded-update-id UUID Обновление, вшитое в бинарник
expo-expect-signature SFV-словарь Клиент требует подписанный ответ
expo-extra-params SFV-словарь Пары ключ/значение, заданные клиентом (см. ниже)
expo-recent-failed-update-ids SFV-список Обновления, недавно упавшие при запуске

Otapush дополнительно принимает platform и runtime-version как query-параметры — именно поэтому точку можно проверить обычным curl:

curl -i "https://your-server.example.com/api/updates/demo/manifest?platform=ios&runtime-version=1.0.0&deviceId=curl-test" \
  -H "expo-protocol-version: 1"

expo-embedded-update-id и expo-recent-failed-update-ids принимаются и игнорируются — Otapush их пока не читает. К чему приводит игнорирование второго, написано ниже, в разделе «Что Otapush записывает, а что нет».

Настоящий ответ с манифестом

Это подлинное тело ответа, снятое с сервера, где в канал prod опубликовано обновление для iOS с версией рантайма 1.0.0 (подпись сокращена по ширине, больше ничего не изменено):

HTTP/1.1 200 OK
expo-protocol-version: 1
expo-sfv-version: 0
cache-control: private, max-age=0
content-type: multipart/mixed; boundary=----ota-boundary-dba97f22-13bb-4b23-926d-2df77d77b7bc

------ota-boundary-dba97f22-13bb-4b23-926d-2df77d77b7bc
content-disposition: form-data; name="manifest"
content-type: application/json; charset=utf-8
expo-signature: sig="AwQcjhmgfRrl4HPWlaAXC4ff0grA8mSo…VntcjKA9OaW4CWcThoz7ig==", keyid="main", alg="rsa-v1_5-sha256"

{"id":"ea9bf56f-2bd4-4fad-8d41-f3b817cbe0cb","createdAt":"2026-08-28T11:14:51.545Z","runtimeVersion":"1.0.0","launchAsset":{"hash":"CzoEzLUgFRIP1Haw5sbm7NCF9YZuIn7A89Qwb0uBv1E","key":"0b3a04ccb52015120fd476b0e6c6e6ecd085f5866e227ec0f3d4306f4b81bf51","fileExtension":".js","contentType":"application/javascript","url":"https://ota.example.com/api/updates/demo/assets?asset=bundles%2F5bc457ff%2Fea9bf56f%2Fbundle.js&runtimeVersion=1.0.0&deviceId=dev-123"},"assets":[{"hash":"VB_sVHYq7M9qOe-SnVWLf4iU9DMpEOt9QEjrvDSnhH8","key":"541fec54762aeccf6a39ef929d558b7f8894f4332910eb7d4048ebbc34a7847f","fileExtension":".png","contentType":"image/png","url":"https://ota.example.com/api/updates/demo/assets?asset=assets%2F541fec…847f.png&runtimeVersion=1.0.0&deviceId=dev-123"}],"metadata":{},"extra":{"message":"fix crash on checkout","gitCommit":"9f2c1ab"}}
------ota-boundary-dba97f22-13bb-4b23-926d-2df77d77b7bc
content-disposition: form-data; name="extensions"
content-type: application/json

{"assetRequestHeaders":{}}
------ota-boundary-dba97f22-13bb-4b23-926d-2df77d77b7bc--

Поле за полем:

  • id — UUID обновления. Именно он вернётся в expo-current-update-id в следующем запросе.
  • createdAt — время публикации, ISO 8601. Клиент упорядочивает обновления по нему.
  • runtimeVersion — всегда равен запрошенному; Otapush никогда не отдаёт несовпадающее обновление.
  • launchAsset — JavaScript-бандл. hash — SHA-256 бандла в base64url без паддинга; key — тот же дайджест в hex. Оба обязательны, и это разные кодировки: сервер, положивший hex в hash, провалит проверку целостности на устройстве.
  • assets — картинки, шрифты, всё, на что ссылается бандл, с тем же правилом кодировок.
  • metadata — Otapush всегда отдаёт {}. Поле существует, чтобы сервер мог помечать обновления для клиентской фильтрации через заголовок expo-manifest-filters; Otapush этот заголовок не шлёт, так что фильтровать не по чему.
  • extra — произвольные данные. Otapush кладёт сюда message и gitCommit публикации, если они были заданы, — их видно из Updates.manifest внутри приложения.

Часть extensions несёт assetRequestHeaders: так сервер может потребовать дополнительные заголовки при скачивании ассетов (например, для схемы с подписанными URL). Otapush отдаёт пустой объект — его ссылки на ассеты самодостаточны.

Почему expo-signature обязан быть строковым item, а не byte sequence

Когда клиент присылает expo-expect-signature, сервер обязан подписать ответ и вернуть подпись в заголовке expo-signature. Подпись покрывает ровно те байты тела этой части, которые ушли в сеть, — строку JSON манифеста или строку JSON директивы. Пересериализация JSON перед проверкой ломает подпись; относитесь к телу как к непрозрачным байтам.

Заголовок — структурированный словарь по RFC 8941. RFC 8941 предлагает два способа нести base64: строку (sig="AwQc…") и byte sequence (sig=:AwQc…:). Byte sequence семантически правильнее, и именно к нему подталкивает внимательное чтение RFC.

И он не работает. expo-updates разбирает заголовок и затем требует, чтобы поле sig было именно строковым item:

val signature = if (sigFieldValue is StringItem) {
  sigFieldValue.get()
} else {
  throw Exception("Structured field sig not found in expo-signature header")
}

Byte sequence разбирается в item другого типа, попадает в ветку else, и клиент сообщает, что подпись отсутствует, — а не что она некорректна. Обновление отвергается, а текст ошибки указывает не туда. Спецификация не говорит, какой тип item использовать, поэтому узнать это можно только по исходникам клиента.

Поэтому Otapush отдаёт — и на части manifest, и на части directive:

expo-signature: sig="<base64 подписи RSA-SHA256>", keyid="main", alg="rsa-v1_5-sha256"

rsa-v1_5-sha256 — единственный алгоритм, который принимает expo-updates. keyid по умолчанию main с обеих сторон и должен совпадать с codeSigningMetadata.keyid в вашем app.json.

Как клиент это проверяет

  • Ключи пер-приложение, генерируются на сервере при создании приложения: пара RSA-2048 плюс настоящий самоподписанный сертификат X.509, выпущенный через @expo/code-signing-certificates — ту же библиотеку, что использует Expo CLI.
  • Приватный ключ никогда не покидает сервер. PEM сертификата публичен: он виден в портале, а otapush init кладёт его в ./certs/certificate.pem и прописывает в app.json. Коммитить этот файл нормально.
  • Клиент проверяет подпись сертификатом, вшитым в бинарник, поэтому ротация ключа означает выпуск нового бинарника. Планируйте ротацию как релиз в стор, а не как OTA.
  • Подпись включается запросом. Если клиент не прислал expo-expect-signature, Otapush не добавляет expo-signature и отдаёт ответ неподписанным — это удобно для curl и объясняет, почему в снятом ответе подписи может не быть вовсе.

Почему ключ deviceid в expo-extra-params обязан быть строчными буквами

Otapush тарифицируется по MAU — monthly active users, то есть уникальные устройства за календарный месяц, — поэтому каждому протокольному запросу нужен стабильный идентификатор устройства. Сервер ищет его в трёх местах, в таком порядке:

  1. query-параметр deviceId (побеждает, если присутствует несколько источников),
  2. заголовок x-device-id,
  3. запись deviceid в структурированном словаре expo-extra-params.

Третий вариант — единственный, который нативный клиент умеет формировать сам, через Updates.setExtraParamAsync. И ключ обязан быть в нижнем регистре:

// Правильно.
await Updates.setExtraParamAsync("deviceid", id);

// Сломано: заголовок до сервера не доедет.
await Updates.setExtraParamAsync("deviceId", id);

RFC 8941 разрешает в ключах словаря только строчные буквы, цифры, _, -, . и *, причём первый символ — строчная буква или *. Сериализатор expo-updates проверяет ровно это:

let failureCondition1 = i == 0 && (c != Character("*") && !c.isLcAlpha)
let failureCondition2 = !(c.isLcAlpha || c.isDigit || c == "_" || c == "-" || c == "." || c == "*")
if failureCondition1 || failureCondition2 {
  throw SerializerError.invalidCharacterInKey(key: key, character: c)
}

Сценарий отказа неприятнее, чем падение. На iOS сериализатор бросает исключение при сборке запроса, ошибка ловится и логируется, а весь заголовок Expo-Extra-Params выбрасывается — запрос всё равно уходит, просто без вашего идентификатора. Сервер видит неопознанный запрос, отвечает 400 deviceId is required, и со стороны приложения это выглядит как «сервер обновлений сломался».

Если идентификатора нет вообще, Otapush отвечает:

{
  "error": "deviceId is required",
  "hint": "Pass a stable device id via the 'deviceId' query parameter or the 'x-device-id' header (expo-updates clients: Updates.setExtraParamAsync('deviceid', id) — key must be lowercase). Set REQUIRE_DEVICE_ID=false to disable this requirement."
}

Разворачиваете сервер сами и не хотите этого требования? Поставьте REQUIRE_DEVICE_ID=false (или 0): запросы без идентификатора обслуживаются как обычно, а их события записываются без привязки к устройству.

Идентификатор обязан быть стабильным между запусками приложения. Новый случайный id на каждый запуск раздувает MAU и способен за сутки выбросить вас за лимит тарифа. Берите постоянный идентификатор — getAndroidId() / getIosIdForVendorAsync() из expo-application, как это сделано в демо-приложении examples/expo-demo/App.tsx. Как всё связать, показано в настройке Expo.

URL ассетов внутри отданного манифеста уже содержат идентификатор запросившего устройства как query-параметр, поэтому скачивания ассетов остаются атрибутируемыми без дополнительной работы на клиенте.

Зачем нужна директива rollBackToEmbedded и что ломается без неё

Вот ловушка. Клиент expo-updates, который уже скачал обновление X, будет запускать X вечно, пока ему не скажут иное. noUpdateAvailable не значит «откатись» — он значит «ты актуален, продолжай». Поэтому если откатить канал в ноль, каждое устройство, успевшее скачать плохой бандл, останется на плохом бандле навсегда, сколько бы раз ни опрашивало сервер.

Ответ протокола — директива rollBackToEmbedded: перестань использовать скачанные обновления и запусти бандл, вшитый в бинарник. Otapush отслеживает это по платформам. Откат единственного обновления канала ставит updateIds[platform] = null и поднимает флаг rollbackToEmbedded[platform], после чего точка манифеста отвечает так:

HTTP/1.1 200 OK
expo-protocol-version: 1
content-type: multipart/mixed; boundary=----ota-boundary-a6b0da9d-24a4-481f-8472-1ca3f717359e

------ota-boundary-a6b0da9d-24a4-481f-8472-1ca3f717359e
content-disposition: form-data; name="directive"
content-type: application/json; charset=utf-8
expo-signature: sig="IOln9cWe0POUZi+9qdXxm7G2Rvfpor0r…", keyid="main", alg="rsa-v1_5-sha256"

{"type":"rollBackToEmbedded"}
------ota-boundary-a6b0da9d-24a4-481f-8472-1ca3f717359e--

Любая последующая публикация или promote в этот канал сбрасывает флаг, и устройства снова идут вперёд — уже от вшитого бандла.

Две честные оговорки:

  • Otapush отправляет директиву без поля parameters.commitTime. Спецификация определяет директиву как { type, parameters?, extra? } и не документирует, что требуется для rollBackToEmbedded; expo-updates 0.26 принимает голую форму — её Otapush и отдаёт. Если будущая версия клиента начнёт требовать commitTime, менять придётся именно это место.
  • Флаг живёт на паре «канал + платформа», а не на версии рантайма. Как только канал перешёл в откаченное состояние для iOS, iOS-клиент получит rollBackToEmbedded для любой версии рантайма — в том числе для той, для которой в этом канале обновлений никогда не было. На практике это безвредно (таким клиентам нечего откатывать), но это отступление от принципа «ответ на пару (platform, runtimeVersion)», и о нём стоит знать, если вы сравниваете поведение с другим сервером.

Откат не текущего обновления отклоняется с 400. Иначе канал молча перескочил бы через версии, которые владелец никогда не смотрел, — см. rollback в справочнике CLI.

Почему URL ассетов строятся из X-Forwarded-Proto и X-Forwarded-Host

URL в манифесте абсолютные, и клиент запрашивает их ровно как написано. Очевидная реализация — взять origin из request.url — ломается, как только сервер оказывается за обратным прокси, терминирующим TLS.

Traefik (или nginx, или облачный балансировщик) принимает от устройства https://ota.example.com/…, терминирует TLS и передаёт приложению обычный http://ota-api:3000/…. Значит, request.url — это http://, манифест уходит с http://-ссылками на ассеты, и скачивание падает на обеих платформах по своим причинам: Android с API 28 по умолчанию блокирует cleartext HTTP, а iOS App Transport Security блокирует его тоже. При этом сам манифест скачался успешно, так что приложение сообщает о сбое загрузки без внятной причины.

Otapush пересобирает публичный origin из заголовков самого прокси, откатываясь на URL запроса, если их нет:

proto = первое значение X-Forwarded-Proto  (иначе: протокол из request.url)
host  = первое значение X-Forwarded-Host   (иначе: хост из request.url)
origin = proto + "://" + host

Оба заголовка приходят через запятую, если прокси несколько; используется только первое значение. Если вы разворачиваете сервер за собственным прокси, убедитесь, что он выставляет оба, — X-Forwarded-Host часто забывают, и без него ссылки на ассеты указывают на внутреннее имя сервиса вместо вашего домена.

Как адресуются и кэшируются ассеты

GET /api/updates/:slug/assets?asset=bundles/<appId>/<updateId>/bundle.js&runtimeVersion=1.0.0&deviceId=<id>
GET /api/updates/:slug/assets?asset=assets/<sha256hex>.png&runtimeVersion=1.0.0&deviceId=<id>
  • Не-бандловые ассеты адресуются по содержимому: ключ хранилища — SHA-256 файла. Два обновления с одной и той же картинкой делят один объект, а устройство, у которого картинка уже есть, пропускает скачивание.
  • Бандлы лежат по пути bundles/<appId>/<updateId>/bundle.js, потому что между обновлениями они не переиспользуются.
  • Ключи проверяются до любого чтения: ключ, содержащий /, обязан начинаться с assets/ или bundles/<appId>/ и не содержать .., иначе запрос получает 403 forbidden asset key. Ключ без слэшей обязан подходить под [a-zA-Z0-9._-]+ и разрешается внутри assets/.
  • Ответы несут cache-control: public, max-age=31536000, immutable — это безопасно именно потому, что ключ есть хеш содержимого.
  • runtimeVersion принимается этой точкой для симметрии с URL манифеста; поиск ассета от него не зависит.
  • Правило про идентификатор устройства действует и здесь. Манифест вшивает его в каждый URL, так что штатному клиенту думать об этом не нужно.

Что Otapush записывает, а что нет

Протокольные точки служат ещё и источником аналитики — отдельного SDK телеметрии нет, и клиент ничего специально не отправляет.

Событие Когда записывается
check Любой запрос манифеста, включая закончившиеся noUpdateAvailable
download Любая успешная отдача ассета или бандла
install Проверка, в которой expo-current-update-id указывает на опубликованное обновление — пишется один раз на пару устройство+обновление

Приём с install стоит проговорить, потому что именно так Otapush считает установки, ничего не спрашивая у приложения: если устройство сообщает, что сейчас выполняет обновление X, значит X скачалось, запустилось и прожило достаточно, чтобы дойти до опроса. Сервер проверяет, что события install для этой пары ещё нет, и записывает его. Вызывать на клиенте ничего не нужно и добавлять в приложение тоже.

События failure сегодня не записываются. Тип события есть в модели данных, эндпоинт статистики его считает, но ни один путь в коде его не пишет — поэтому счётчик отказов в портале сейчас остаётся нулевым. Информация при этом доступна: expo-updates в каждом запросе шлёт expo-recent-failed-update-ids со списком обновлений, упавших при запуске. Otapush этот заголовок пока не читает. Пока это не сделано, для падений на старте используйте систему сбора крашей, а не столбец с отказами, и считайте настоящим сигналом застывший счётчик install после публикации.

MAU считается по различным идентификаторам устройств в разрезе пары «приложение + календарный месяц», upsert выполняется на каждом протокольном запросе. Превышение лимита не останавливает раздачу обновлений — в ответе статистики просто поднимается флаг overLimit. Какие бывают лимиты, см. в тарифах.

Ошибки, которые может вернуть точка манифеста

Статус Тело Причина
400 Unsupported protocol version. Expected either 0 or 1. expo-protocol-version вне 0/1
400 Unsupported platform. Expected either ios or android. Нет или не распознан expo-platform
400 No runtimeVersion provided. Нет expo-runtime-version
400 deviceId is required (+ hint) Идентификатора устройства нет ни в одном из трёх мест
404 app not found Неизвестный слаг
404 channel '<name>' not found Неизвестный query-параметр channel
404 No update available for this platform/runtimeVersion. Только протокол 0 — в версии 1 это директива noUpdateAvailable

Где Otapush не доходит до спецификации

Сказано прямо, чтобы вы поняли, подходит ли вам Otapush, до того как выясните это тяжёлым путём:

  • expo-manifest-filters не отправляется. Клиентская фильтрация сохранённых обновлений по metadata манифеста недоступна, а metadata всегда {}.
  • expo-server-defined-headers не отправляется. Механизма, которым сервер заставляет клиента хранить и возвращать свои заголовки, нет.
  • expo-embedded-update-id игнорируется. Сервер не сравнивает вшитое обновление с состоянием канала; эту работу делает точное совпадение версии рантайма.
  • expo-recent-failed-update-ids игнорируется, отсюда и отсутствие событий failure (см. выше).
  • Ответы всегда multipart/mixed, голого манифеста application/expo+json не бывает.
  • rollBackToEmbedded идёт без parameters.
  • Версии рантайма сверяются точно. Ни диапазонов, ни политик сопоставления на сервере нет: обновление, опубликованное для 1.0.0, невидимо для бинарника 1.0.1. Это сделано нарочно — это та граница безопасности, которая не пускает JavaScript в нативный бинарник, под который он не собирался.

Если вам нужно что-то из этого списка, помните: единственное, от чего зависит ваш клиент, — протокольная точка. Свой сервер можно поставить по тому же URL, не трогая приложение.

Вопросы и ответы

Нужна ли своя клиентская библиотека, чтобы пользоваться Otapush?

Нет. Otapush — это сервер, а не SDK. Штатный пакет expo-updates, идущий с вашим Expo SDK, и есть весь клиент. Всё специфичное для Otapush — URL, сертификат, версия рантайма — это конфигурация в app.json (или в AndroidManifest.xml и Expo.plist для bare React Native), которую за вас пишет otapush init.

Можно ли перейти с EAS Update на Otapush без изменений в коде приложения?

Да, но понадобится новый бинарник. URL обновлений и сертификат подписи вшиты в приложение, поэтому смена сервера — это релиз в стор. JavaScript при этом не трогается: Updates.checkForUpdateAsync() и остальные работают точно так же.

Одна ли реализация покрывает iOS и Android?

Да. Протокол платформенно-нейтрален, и оба нативных клиента реализуют один и тот же поток. Otapush хранит состояние канала по платформам, поэтому один канал может отдавать разные обновления на iOS и Android — публикация для одной платформы никогда не задевает другую.

Что будет, если runtimeVersion не совпал?

Клиент получит noUpdateAvailable (протокол 1) или 404 (протокол 0) и продолжит работать с тем, что у него есть. Никакого нечёткого сопоставления и никакого отката на «ближайшую версию» нет. Именно этот механизм не даёт JavaScript-бандлу попасть в бинарник, нативный код которого не сможет его выполнить.

Почему устройство получает 400 deviceId is required?

Почти всегда — из-за регистра ключа: Updates.setExtraParamAsync("deviceId", …) вместо "deviceid". На iOS заголовок молча выбрасывается, и запрос приходит вообще без идентификатора. Исправьте ключ — или передайте id как query-параметр deviceId, чтобы подтвердить диагноз за пару секунд.

Обязательна ли подпись кода?

Нет. Если клиент не присылает expo-expect-signature, ответы уходят неподписанными и клиент их принимает. В проектах, настроенных через otapush init, подпись включена по умолчанию, и выключать её в проде незачем: пара ключей генерируется за вас, а приватный ключ остаётся на сервере.

Можно ли проверить протокол через curl?

Да — ради этого платформа и версия рантайма и принимаются как query-параметры:

curl -i "https://your-server.example.com/api/updates/<slug>/manifest?platform=android&runtime-version=1.0.0&deviceId=curl-test" \
  -H "expo-protocol-version: 1"

Вы получите настоящее multipart-тело, без подписи. Добавьте -H "expo-current-update-id: <uuid>", чтобы увидеть путь noUpdateAvailable, и -H "expo-expect-signature: true" — чтобы увидеть подписанный ответ.

Сервер сам доставляет обновления на устройства?

Нет. Протокол работает только на вытягивание: клиент опрашивает сервер при запуске (или когда вы вызываете checkForUpdateAsync), сервер отвечает. Push-канала нет, пробуждения тихим уведомлением нет, и заставить обновиться устройство, на котором приложение не запущено, невозможно. «Откатить прямо сейчас» означает «следующий опрос каждого устройства вернёт откат» — устройство, которое приложение не открывает, об этом не узнает никогда.

Как быстро опубликованное обновление доходит до пользователей?

Со скоростью, с которой они открывают приложение, при настройках по умолчанию: expo-updates проверяет обновления при запуске и по умолчанию применяет новый бандл при следующем запуске. Пользователь, открывший приложение дважды, увидит его сразу; тот, кто не открывает вовсе, — никогда. К Otapush эти сроки отношения не имеют: это поведение клиента, а сократить его можно через Updates.reloadAsync().

Что такое id обновления и можно ли его задать?

Это UUID, сгенерированный сервером, и задать его нельзя. Он попадает в манифест, возвращается в expo-current-update-id и служит ключом, к которому привязываются события install. Чтобы связать обновление со своей схемой версий, используйте поля message и gitCommit при публикации — Otapush пробрасывает их в объект extra манифеста.