@memealerts/api
v1.3.0
Published
Типизированный SDK для MemeAlerts External API: OAuth, API v1, приложения платформы, виджеты и realtime-события
Maintainers
Readme
@memealerts/api
Типизированный SDK для MemeAlerts External API: OAuth, работа с профилем стримера, приём донатов и стикеров в реальном времени, приложения платформы, виджеты и вебхуки.
Пакет закрывает всё, что нужно стороннему приложению, и берёт на себя то, на чём чаще всего ломается ручная интеграция: одноразовые refresh token, подпись вебхуков по сырому телу и порядок подключения к Centrifugo.
- Что это и для кого
- Установка
- Быстрый старт
- Авторизация
- API v1
- Realtime
- Приложения платформы
- Виджет
- Вебхуки
- Обработка ошибок
- Справочник методов
- Разработка
Что это и для кого
Приложение на платформе MemeAlerts состоит из трёх частей, и у каждой свои требования к безопасности. Поэтому у пакета три точки входа.
| Импорт | Где выполняется | Что даёт |
|--------|-----------------|----------|
| @memealerts/api | Только бэкенд | OAuth, вызовы API v1, обмен токена запуска, проверка подписи вебхуков |
| @memealerts/api/widget | Браузер, внутри iframe | Чтение launch_token, postMessage-контракт с MemeAlerts |
| @memealerts/api/realtime | Бэкенд или браузер | Донаты, стикеры и события канала через Centrifugo |
Деление не косметическое. client_secret и webhookSecret существуют только на сервере, а
серверная точка входа использует node:crypto и в браузерный бандл не собирается. Виджет
секретов не имеет вообще: его код открыт любому зрителю на странице.
Бэкенд приложения Виджет в iframe
├── OAuth: authorize, code ├── launch_token из URL ──┐
├── API v1 └── postMessage: донат, │
├── launch/exchange ◄───────────────── платёж приложению │
└── проверка вебхуков ◄── billingUrl │
│ │
└── socket_connection_token ──► Centrifugo ◄──────────┘Одну задачу пакет намеренно не решает: управление самими приложениями — создание, модерацию, тестировщиков, отчёты о доходе. Эти методы работают под сессией пользователя MemeAlerts, а не под токеном приложения, и делаются из личного кабинета.
Установка
npm install @memealerts/apiДля realtime дополнительно понадобится centrifuge — он объявлен peer-зависимостью, чтобы
у тех, кому события не нужны, ничего лишнего не оказалось в бандле:
npm install centrifugeТребования: Node.js 18+ (используется встроенный fetch). Пакет поставляется в ESM и CJS
с типами, так что import и require работают одинаково.
import { MemeAlertsClient } from '@memealerts/api';const { MemeAlertsClient } = require('@memealerts/api');Быстрый старт
Перед началом зарегистрируйте приложение и получите client_id и client_secret.
Полные примеры лежат в examples/express-backend.ts
и examples/widget.ts.
import { MemeAlertsClient, SCOPES } from '@memealerts/api';
const client = new MemeAlertsClient({
clientId: process.env.MA_CLIENT_ID!,
clientSecret: process.env.MA_CLIENT_SECRET!,
});
// 1. Отправляем пользователя авторизовываться
const url = client.oauth.getAuthorizeUrl({
redirectUri: 'https://my-app.com/callback',
scopes: [SCOPES.USER_SHOW, SCOPES.DONATION_SUBSCRIBE],
state: 'случайная строка',
});
// 2. В обработчике callback меняем код на токены
const tokens = await client.oauth.exchangeCode({ code, redirectUri: 'https://my-app.com/callback' });
await client.setTokens(tokens);
// 3. Работаем от имени стримера
const profile = await client.users.me();
console.log(profile.name);Авторизация
MemeAlerts использует OAuth 2.0 с типом authorization_code.
1. Ссылка авторизации
const state = crypto.randomUUID();
req.session.oauthState = state;
const url = client.oauth.getAuthorizeUrl({
redirectUri: 'https://my-app.com/callback',
scopes: [SCOPES.USER_SHOW, SCOPES.DONATION_SUBSCRIBE],
state,
});redirectUri должен точно совпадать с одним из зарегистрированных в приложении, а scopes —
входить в разрешённый ему список.
Доступные права:
| Константа | Значение | Что открывает |
|-----------|----------|---------------|
| SCOPES.USER_SHOW | oauth-user-show | Профиль и socket_connection_token |
| SCOPES.DONATION_SUBSCRIBE | oauth-donation-subscribe | Канал донатов |
| SCOPES.STICKER_SUBSCRIBE | oauth-sticker-subscribe | Канал стикеров |
| SCOPES.EVENTS_READ | oauth-events-read | Общий канал событий |
| SCOPES.BONUS_GIVE | oauth-bonus-give | Выдача бонусов и список саппортеров |
2. Callback
state возвращается неизменным. Сверьте его до обмена кода — это защита от CSRF, и без
неё чужой запрос может привязать к вашей сессии чужой аккаунт.
app.get('/callback', async (req, res) => {
if (req.query.state !== req.session.oauthState) {
return res.status(400).send('Некорректный state');
}
const tokens = await client.oauth.exchangeCode({
code: String(req.query.code),
redirectUri: 'https://my-app.com/callback',
});
await client.setTokens(tokens);
res.redirect('/');
});Код одноразовый и живёт 10 минут.
3. Хранение токенов
TokenPair содержит accessToken, refreshToken, expiresIn и посчитанный expiresAt.
Клиент сам обновляет пару за 60 секунд до истечения и записывает новую в хранилище.
По умолчанию используется MemoryTokenStore — этого достаточно для скрипта, но не для сервера:
токены пропадут при перезапуске, а с несколькими воркерами каждый будет держать свою копию.
Подставьте своё хранилище:
import { MemeAlertsClient, type TokenPair, type TokenStore } from '@memealerts/api';
class DatabaseTokenStore implements TokenStore {
constructor(private readonly streamerId: string) {}
async load(): Promise<TokenPair | null> {
return await db.tokens.findOne({ streamerId: this.streamerId });
}
async save(tokens: TokenPair): Promise<void> {
await db.tokens.upsert({ streamerId: this.streamerId }, tokens);
}
}
const client = new MemeAlertsClient({
clientId: process.env.MA_CLIENT_ID!,
clientSecret: process.env.MA_CLIENT_SECRET!,
tokenStore: new DatabaseTokenStore(streamerId),
});Один клиент обслуживает одного стримера. Для нескольких заведите по клиенту на каждого — объект дешёвый, состояние всё равно живёт в хранилище.
Одноразовый refresh token
Каждое обновление гасит старый refresh token и выдаёт новый. Отсюда два следствия, которые SDK берёт на себя:
- Новую пару обязательно сохранить. Клиент делает это сам через
TokenStore— если вы вызываетеoauth.refresh()напрямую, сохраните результат руками. - Параллельные обновления склеиваются. Два одновременных запроса с одним refresh token
означали бы, что второй наверняка получит
invalid_grant.OAuthClientдержит обновления в полёте по значению токена и отдаёт всем ожидающим один результат.
invalid_grant против server_error
Обе ситуации выглядят как «не удалось обновить токен», но лечатся по-разному, поэтому у ошибки есть два готовых флага:
import { MemeAlertsOAuthError } from '@memealerts/api';
try {
await client.users.me();
} catch (error) {
if (error instanceof MemeAlertsOAuthError) {
if (error.requiresReauth) {
// Грант мёртв: токен уже использован, отозван или приложение удалено с канала.
// Повторять бессмысленно — отправляйте стримера авторизовываться заново.
await db.tokens.delete({ streamerId });
return redirectToAuthorize();
}
if (error.retryable) {
// Сбой на нашей стороне. Старый refresh token остался валидным — повторите позже.
return scheduleRetry();
}
}
throw error;
}API v1
Все методы сами следят за сроком жизни access token.
Профиль
const profile = await client.users.me();
profile.id; // нужен для имён каналов Centrifugo
profile.socket_connection_token; // токен подключения, живёт недолго
profile.channel.unique_link;channel.currency_name_declensions может быть null — у части каналов склонения не заполнены,
проверяйте перед обращением. Требует scope oauth-user-show.
Выдача бонуса
await client.users.giveBonus({ userId: '633edfa2e6cb81985b72e7aa', value: 50 });value — целое от 1 до 1000. Получатель должен быть саппортером стримера, иначе запрос вернёт
ошибку. Требует scope oauth-bonus-give.
Саппортеры
const { data, total } = await client.users.supporters({ skip: 0, limit: 50, query: 'ivan' });limit по умолчанию 20, максимум 100. Требует scope oauth-bonus-give.
Realtime
События приходят через Centrifugo. Порядок подключения задан сервером: сначала WebSocket
с токеном подключения, потом выданный Centrifugo client id, и только с ним выдаются токены
подписок. MemeAlertsRealtime проходит эти шаги сам.
import { MemeAlertsClient } from '@memealerts/api';
import { MemeAlertsRealtime, tokenSourceFromClient } from '@memealerts/api/realtime';
const profile = await client.users.me();
const realtime = new MemeAlertsRealtime({
userId: profile.id,
tokens: tokenSourceFromClient(client),
});
realtime
.onDonation(donation => {
console.log(`${donation.username}: ${donation.amount} ${donation.currencyName}`);
// Донат пришёл через виджет приложения — у обычных донатов поля нет
if (donation.clientId) console.log('из приложения', donation.clientId);
})
.onSticker(sticker => console.log(sticker.stickerName, sticker.price))
.onScopeDenied(channels => console.warn('нет доступа к каналам:', channels))
.onError(error => console.error(error));
await realtime.connect();Каналы собираются из userId; по умолчанию поднимаются все три. Ограничить набор можно
опцией channels: ['donation'].
| Канал | Scope | Колбэк |
|-------|-------|--------|
| $alerts:donation_<id> | oauth-donation-subscribe | onDonation |
| $alerts:sticker_<id> | oauth-sticker-subscribe | onSticker |
| $alerts:events_<id> | oauth-events-read | onEvent, onCoinPurchase |
Общий канал шлёт разнородные события, размеченные полем type:
realtime.onEvent(event => {
if (event.type === 'donation') console.log(event.data.amount);
if (event.type === 'coin_purchase') console.log(event.data.price, event.data.priceCurrency);
});Донат приходит и в свой канал, и в общий — если подписаны на оба, обработаете его дважды.
Если событий нет
Каналы, на которые у токена нет нужного scope, сервер отбрасывает молча: ответ приходит
успешный, просто без них. Поэтому есть onScopeDenied — иначе отсутствие событий выглядело бы
как ошибка в своём коде, а причина оставалась бы невидимой.
Подписки создаются с обновляемым токеном: источник в OBS живёт часами, и статического токена подписки на такой срок не хватило бы.
Realtime без client_secret
Если токены приезжают со своего бэкенда, а секрета в этом процессе нет, передайте свой источник токенов:
const realtime = new MemeAlertsRealtime({
userId,
tokens: {
getConnectionToken: async () => await fetch('/api/ma/connection-token').then(r => r.text()),
getSubscriptionTokens: async params => await fetch('/api/ma/subscribe', {
method: 'POST',
body: JSON.stringify(params),
}).then(r => r.json()),
},
});Приложения платформы
Приложение открывается в одном из трёх контекстов, и в каждом получает свой набор данных:
| Контекст | Где открыт | Стример | Зритель |
|----------|------------|---------|---------|
| settings | Кабинет стримера | да | нет |
| obs | Источник в OBS | да | нет |
| viewer | Страница канала, у зрителя | да | да |
В любом контексте URL запуска содержит параметр launch_token:
https://my-app.com/viewer?launch_token=9f86d081...Обмен токена запуска
import { readLaunchToken } from '@memealerts/api/widget';
// В браузере: достаём токен и отправляем на свой бэкенд
const launchToken = readLaunchToken();
await fetch('/api/session', { method: 'POST', body: JSON.stringify({ launchToken }) });// На бэкенде: меняем на данные о запуске
const session = await client.launch.exchange(launchToken);
session.context; // 'settings' | 'obs' | 'viewer'
session.streamer.id; // владелец канала
session.viewer?.id; // только в контексте viewer
session.scopes;
session.token; // пара токенов, всегда выпущенная на стримераОбмен возможен только на бэкенде. Он требует
client_secret, а тот в браузере оказаться не должен ни при каких условиях.
Токены контекстов settings и viewer одноразовые и живут 5 минут, повторный обмен вернёт
ошибку. Ссылка для obs стабильна и не протухает: источник в браузере OBS перезагружается
когда угодно, и запросить свежий токен в этот момент некому.
Пара токенов всегда выпускается на стримера, даже в контексте viewer: приложение работает
с API от имени того, кто его установил, а зритель приходит как данные, а не как субъект.
Сохраните её в свой TokenStore и работайте обычным клиентом:
await client.setTokens(session.token);Виджет
Точка входа @memealerts/api/widget собирается в браузерный бандл и не содержит ни секретов,
ни серверных зависимостей.
Рукопожатие
import { MemeAlertsWidget } from '@memealerts/api/widget';
const widget = new MemeAlertsWidget();
try {
const { context, streamerId } = await widget.ready();
showDonateButton();
} catch {
// Родитель не ответил: виджет открыт в OBS или по прямой ссылке.
// Это не ошибка интеграции, но кнопки платежей показывать нельзя — отправлять сообщение некому.
hideDonateButton();
}Донат стримеру
const result = await widget.requestDonation({
amount: 500,
message: 'Го катку',
isAnonymous: false,
});
if (result.status === 'paid') unlockFeature(result.donationId);Виджет не запрашивает ссылку на оплату сам и не показывает платёжные системы. Он только
называет сумму, а попап рисует MemeAlerts под сессией самого зрителя: токен из launch/exchange
привязан к стримеру, и заплатить им за зрителя нельзя.
Сумма и сообщение остаются редактируемыми в нашей модалке, а все ограничения — минимальная сумма стримера, длина сообщения, разрешены ли анонимные донаты — проверяет бэкенд.
Статусы: paid, cancelled, failed. Отмена — обычный результат, а не исключение.
Платёж приложению
Зритель платит не стримеру, а самому приложению — за подписку, доступ к функции, внутреннюю валюту:
const result = await widget.requestPurchase({ amount: 300, payload: 'order-8842' });payload необязателен, до 512 символов. MemeAlerts его не разбирает и возвращает как есть
в вебхуке partner_payment.completed — это ваш способ связать платёж со своим заказом.
Выдавайте оплаченное по вебхуку, а не по результату postMessage: status: 'paid' говорит
лишь о том, что зритель дошёл до конца в интерфейсе, и приходит в браузер, где его можно
подделать.
Ссылка для OBS и режим стримера
В контексте settings виджет может сам открыть то, что стример видит кнопками в шапке:
await widget.openObsLink(); // попап со ссылкой для встраивания в OBS
await widget.openStreamerMode(); // приложение в режиме стримера в отдельном попапеОтвета на эти команды нет: промис разрешается, как только сообщение ушло родителю.
В других контекстах методы отклоняются ошибкой — MemeAlerts такие сообщения там игнорирует.
Ссылка для OBS имеет смысл, только если у приложения задан obsUrl.
Один запрос за раз
В ответных сообщениях нет идентификатора запроса — сопоставить их можно только по типу. Поэтому SDK держит не больше одного незавершённого запроса каждого вида и отклоняет второй понятной ошибкой, вместо того чтобы разрешить не тот промис. Донат и платёж при этом не мешают друг другу.
Настройки
new MemeAlertsWidget({
parentOrigin: 'https://memealerts.com', // с чьего origin принимать сообщения
readyTimeoutMs: 5_000,
resultTimeoutMs: 10 * 60 * 1000, // зритель платит не быстро
});Сообщения принимаются, только если пришли от окна, встроившего этот iframe, и с ожидаемого
origin. Проверка встроена и отключить её нельзя, но на деве parentOrigin можно переставить
на свой хост.
Когда виджет больше не нужен, вызовите widget.destroy(): он снимет слушатель и отклонит
незавершённые запросы.
Вебхуки
Если в конфиге приложения заполнен billingUrl, на него приходит уведомление о каждом
проведённом платеже и о каждом его откате.
Подпись считается по сырому телу
Это самая частая ошибка интеграции.
express.json()разбирает тело и оставляет объект; обратная сборка черезJSON.stringifyдаёт другие байты — те же данные, но иные пробелы и порядок ключей, — и подпись не сходится. Тело нужно сохранить до разбора JSON.
import express from 'express';
import { handleWebhook, WebhookVerificationError } from '@memealerts/api';
// Именно raw, и именно на этом маршруте — до глобального express.json()
app.post('/webhooks/memealerts', express.raw({ type: 'application/json' }), async (req, res) => {
try {
await handleWebhook({
secret: process.env.MA_WEBHOOK_SECRET!,
headers: req.headers,
rawBody: req.body,
onPaymentCompleted: async (event, ctx) => {
await grantReward(event.orderId, event.commission.amount, ctx.deliveryId);
},
onPartnerPaymentCompleted: async event => {
await activateSubscription(event.orderId, event.payload);
},
onPaymentRefunded: async event => await revokeReward(event.orderId),
onPartnerPaymentRefunded: async event => await revokeSubscription(event.orderId),
});
res.sendStatus(200);
} catch (error) {
if (error instanceof WebhookVerificationError) return res.sendStatus(401);
throw error;
}
});Подпись проверяется по webhookSecret приложения — не по client_secret, тот хранится у нас
только хешем и подписать им нечего.
События
| Событие | Когда приходит | Ключевое поле |
|---------|----------------|---------------|
| payment.completed | Донат стримеру через виджет проведён | commission.amount — ваша доля |
| partner_payment.completed | Прямой платёж приложению проведён | amount — ваша доля, payload — ваша строка |
| payment.refunded | Донат откатили возвратом или чарджбэком | amount — сколько снимается |
| partner_payment.refunded | Прямой платёж откатили | amount, reason |
WebhookEvent — размеченное объединение по полю event, так что TypeScript сам сузит тип
внутри if:
import { parseWebhookEvent } from '@memealerts/api';
const event = parseWebhookEvent(rawBody);
if (event.event === 'payment.completed') {
console.log(event.commission.amount, event.commission.rate);
}Доля с доната режется из того, что остаётся стримеру, — после комиссии платёжной системы и комиссии платформы. У прямого платежа стример не получает ничего: его канал только контекст, в котором зритель запустил виджет.
Идемпотентность и повторы
X-MA-Delivery уникален для доставки и не меняется между попытками — используйте его как
ключ дедупликации, он приходит в ctx.deliveryId.
Доставка считается успешной при любом коде 2xx. Любой другой ответ, таймаут в 10 секунд или
сетевая ошибка приводят к повтору с нарастающей паузой: 1 минута, 5 минут, 30 минут, 2 часа,
6 часов. После шестой неудачной попытки доставка помечается FAILED и больше не повторяется.
Поэтому отвечайте 200 как можно раньше, а тяжёлую работу уводите в очередь: медленный
обработчик выглядит для нас как таймаут и приводит к повторной доставке.
Если ваш биллинг пролежал дольше всех повторов, оповещение о любом платеже можно отправить
заново из раздела «Платежи» в кабинете разработчика. Это новая доставка с новым
X-MA-Delivery, поэтому дедуплицируйте выдачу ещё и по orderId: тело события то же самое,
а у откаченного платежа повтор приходит событием *.refunded.
Возвраты и чарджбэки
Проведённый платёж может быть отменён: зритель попросил возврат или оспорил списание в банке.
Тогда приходит парное событие, и начисление снимается — сумма вычитается из ближайшей месячной
выплаты. Отвяжите по orderId то, что вы выдали за этот платёж. Событие приходит один раз
на начисление; отмена ошибочного чарджбэка отдельным событием не сопровождается.
Проверка подписи отдельно
Если диспетчер handleWebhook не подходит, проверяйте подпись сами:
import { verifyWebhookSignature } from '@memealerts/api';
const ok = verifyWebhookSignature({
secret: process.env.MA_WEBHOOK_SECRET!,
signature: req.headers['x-ma-signature'] as string,
timestamp: req.headers['x-ma-timestamp'] as string,
rawBody: req.body,
});Сравнение идёт за постоянное время, а запросы со слишком старым X-MA-Timestamp отклоняются:
допуск по умолчанию пять минут, меняется опцией toleranceMs. Есть и вариант с исключением —
assertWebhookSignature, из его сообщения видно, что именно не сошлось.
Обработка ошибок
| Класс | Когда | Полезные поля |
|-------|-------|---------------|
| MemeAlertsApiError | REST-запрос вернул не 2xx | status, endpoint, body |
| MemeAlertsOAuthError | Ошибка /oauth/token | code, requiresReauth, retryable |
| WebhookVerificationError | Подпись не сошлась или тело битое | message |
| MemeAlertsWidgetError | Проблема postMessage-обмена | message |
| MemeAlertsError | Базовый класс всех перечисленных | message |
import { MemeAlertsApiError, MemeAlertsOAuthError } from '@memealerts/api';
try {
await client.users.giveBonus({ userId, value: 50 });
} catch (error) {
if (error instanceof MemeAlertsOAuthError && error.requiresReauth) {
return redirectToAuthorize();
}
if (error instanceof MemeAlertsApiError) {
if (error.status === 403) return; // нет scope oauth-bonus-give
if (error.status === 429) return scheduleRetry();
}
throw error;
}Справочник методов
@memealerts/api
| Метод SDK | Эндпоинт |
|-----------|----------|
| oauth.getAuthorizeUrl(params) | GET /oauth/authorize |
| oauth.exchangeCode(params) | POST /oauth/token (authorization_code) |
| oauth.refresh(refreshToken) | POST /oauth/token (refresh_token) |
| users.me() | GET /api/v1/user/oauth |
| users.giveBonus(params) | POST /api/v1/user/give-bonus |
| users.supporters(query) | GET /api/v1/user/supporters |
| centrifugo.subscriptionTokens(params) | POST /api/v1/centrifuge/subscribe |
| launch.exchange(launchToken) | POST /api/v1/launch/exchange |
| client.setTokens(tokens) | — |
| client.getAccessToken() | — (обновит токен при необходимости) |
| verifyWebhookSignature(params) | — |
| assertWebhookSignature(params) | — |
| parseWebhookEvent(rawBody) | — |
| handleWebhook(params) | — |
@memealerts/api/widget
| Метод SDK | Сообщение postMessage |
|-----------|-----------------------|
| readLaunchToken(search?) | — (параметр launch_token в URL) |
| widget.ready() | ждёт ma:ready |
| widget.requestDonation(params) | ma:donate:request → ma:donate:result |
| widget.requestPurchase(params) | ma:purchase:request → ma:purchase:result |
| widget.openObsLink() | ma:obs-link:open, только settings, без ответа |
| widget.openStreamerMode() | ma:streamer-mode:open, только settings, без ответа |
| widget.destroy() | — |
@memealerts/api/realtime
| Метод SDK | Что делает |
|-----------|------------|
| realtime.connect() | WebSocket, client id, токены подписок, подписка на каналы |
| realtime.disconnect() | Снимает подписки и закрывает соединение |
| realtime.onDonation(cb) | $alerts:donation_<id> |
| realtime.onSticker(cb) | $alerts:sticker_<id> |
| realtime.onEvent(cb) | $alerts:events_<id>, размечен по type |
| realtime.onCoinPurchase(cb) | Покупки валюты канала из общего канала |
| realtime.onScopeDenied(cb) | Каналы, токены на которые не выданы |
| realtime.onConnected(cb) / onDisconnected(cb) / onError(cb) | Состояние соединения |
| tokenSourceFromClient(client) | Источник токенов поверх MemeAlertsClient |
Константы: SCOPES, WEBHOOK_EVENTS, WEBHOOK_HEADERS, WIDGET_MESSAGES,
DEFAULT_BASE_URL, DEFAULT_WS_URL, билдеры каналов donationChannel, stickerChannel,
eventsChannel.
Разработка
npm install
npm run build # tsup: ESM, CJS и .d.ts на три точки входа
npm test # vitest
npm run type-check
npm run lintПубликация:
npm version <patch|minor|major>
npm publish --access publicprepublishOnly прогоняет проверку типов, линт, тесты и сборку, так что сломанная версия
в реестр не уедет.
