Real-time

Events API

Your app opens one connection to Velora and every event on a channel — follows, subs, Volts, chat, redemptions, polls, giveaways — arrives on it as it happens. There is no server for you to host and no endpoint for us to reach: the connection is outbound from your app, so it works from a desktop tool, a Streamer.bot action, or a laptop behind NAT. If you can host a public HTTPS endpoint and would rather we push to it, use webhooks instead.

Two transports carry the same events. Pick WebSocket if your language has a Socket.IO client; pick SSE if it does not.

No webhook server required. Unlike webhooks, the Events API doesn't require you to host a server. Your application connects directly to Velora and receives events in real-time. This is ideal for desktop applications, stream tools, and situations where hosting a webhook endpoint isn't practical.

The two transports

WebSocketwss://api.velora.tv/ws/events

Persistent bidirectional connection using Socket.IO. Automatic reconnection, and the only transport that can also request data (see channel point rewards below).

Server-Sent EventsGET /api/events/stream

HTTP-based streaming for simpler clients. Great for languages without good Socket.IO support.

Connecting over WebSocket

The WebSocket endpoint uses Socket.IO for reliable connections with automatic reconnection. With a valid token you are auto-subscribed to your own channel — there is no extra subscribe call.

Connection URL
wss://api.velora.tv/ws/events
JavaScript · socket.io-client
import { io } from 'socket.io-client';

const socket = io('wss://api.velora.tv/ws/events', {
  auth: {
    token: 'YOUR_ACCESS_TOKEN'
  },
  // Or pass token as query parameter:
  // query: { token: 'YOUR_ACCESS_TOKEN' }
});

// Connection established. With a valid token you are AUTO-SUBSCRIBED to your
// own channel — no extra subscribe call is needed; your events start flowing.
socket.on('connected', (data) => {
  console.log('Connected to Events API');
  console.log('Channel:', data.channelUsername);   // your channel's username
  console.log('Authenticated:', data.authenticated); // true when a valid token was sent
  console.log('Auto-subscribed:', data.autoSubscribed);
});

// Three ways to receive events — use whichever fits:

// 1) A single generic handler for ALL events ({ event, timestamp, data } envelope):
socket.on('event', ({ event, data, timestamp }) => {
  console.log('Event:', event, data, timestamp);
});

// 2) socket.onAny — also catches every event, by name:
socket.onAny((eventName, payload) => {
  console.log('Event:', eventName, payload);
});

// 3) Listen for specific events by name (payload is the raw event data):
socket.on('channel.follow', (payload) => {
  console.log('New follower:', payload);
});
socket.on('channel.subscribe', (payload) => {
  console.log('New subscriber:', payload);
});

// Handle disconnection
socket.on('disconnect', (reason) => {
  console.log('Disconnected:', reason);
});
C# · Streamer.bot
using SocketIOClient;

var socket = new SocketIO("wss://api.velora.tv/ws/events", new SocketIOOptions
{
    Auth = new { token = "YOUR_ACCESS_TOKEN" }
});

socket.On("connected", response =>
{
    var data = response.GetValue<ConnectedData>();
    Console.WriteLine($"Connected as {data.ChannelUsername}");
});

socket.On("event", response =>
{
    var payload = response.GetValue<EventPayload>();
    Console.WriteLine($"Event: {payload.Event}");
    // Handle the event...
});

await socket.ConnectAsync();

Connecting over SSE

SSE is a simpler alternative that works over standard HTTP. Great for languages without good Socket.IO support.

Endpoint
GET https://api.velora.tv/api/events/stream

Headers

HeaderValue
AuthorizationBearer YOUR_ACCESS_TOKEN
Accepttext/event-stream

Optional: filter events

You can filter which events you receive using the events query parameter:

Filtered subscription
GET /api/events/stream?events=channel.follow,channel.subscribe,chat.message
JavaScript · EventSource
const eventSource = new EventSource(
  'https://api.velora.tv/api/events/stream',
  {
    headers: {
      'Authorization': 'Bearer YOUR_ACCESS_TOKEN'
    }
  }
);

// Connection established
eventSource.addEventListener('connected', (e) => {
  const data = JSON.parse(e.data);
  console.log('Connected:', data.channelUsername);
});

// Listen for specific event types
eventSource.addEventListener('channel.follow', (e) => {
  const payload = JSON.parse(e.data);
  console.log('New follower:', payload.data.username);
});

eventSource.addEventListener('channel.subscribe', (e) => {
  const payload = JSON.parse(e.data);
  console.log('New sub:', payload.data.username);
});

// Or listen for all events
eventSource.onmessage = (e) => {
  const payload = JSON.parse(e.data);
  console.log('Event:', payload.event, payload.data);
};

eventSource.onerror = (e) => {
  console.error('SSE error:', e);
};
curl
curl -N -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
     -H "Accept: text/event-stream" \
     "https://api.velora.tv/api/events/stream"

Authentication

Both connection methods authenticate with a standard OAuth access token — the same token your app already uses for chat and REST. There is no separate "broadcaster app" to register: any user who authorizes your app becomes a broadcaster you can receive events for. No special scopes are required.

No special permissions needed. Any valid access token can receive events for the channel that issued it. If your token already works for chat/REST, it works here.

Getting an access token

Use the standard OAuth flow to get a user's access token. See the Authentication Guide for details.

Before the OAuth flow will work: your app needs at least one Redirect URI registered (Developer Dashboard → your app → Edit details). Without one, the authorize URL has nowhere to return and will error.

The event envelope

All events follow a consistent format:

Event envelope
{
  "event": "channel.follow",
  "timestamp": "2026-02-10T12:00:00.000Z",
  "data": {
    "userId": "user_abc123",
    "username": "newviewer",
    "displayName": "NewViewer",
    "followedAt": "2026-02-10T12:00:00.000Z"
  }
}
FieldTypeDescription
eventstringThe event type (e.g., "channel.follow")
timestampstringISO 8601 timestamp when the event occurred
dataobjectEvent-specific payload data

Match identity by userId, not username. Usernames can be changed by the user at any time; the userId (UUID) is immutable, so use it as the key when you need to correlate events (e.g. a channel.subscription.end back to the original channel.subscribe).

Test your handlers without waiting for a real event. You don't need a real viewer to follow, sub, tip, or raid to test your integration. In your app dashboard → Testing tab, click any event type to fire a synthetic event at your own channel. It goes out through this exact pipeline (SSE + WebSocket + webhooks) with byte-identical payload shapes and isTest: true— so you can watch your handler process it end to end. No money moves; nothing is written to any ledger.

Available events

These names are for the Events API only. Webhooks use a different vocabulary for some of the same events — a follow is channel.follow here but user.follow in a webhook subscription. The Events API also carries events webhooks do not have at all, such as polls, goals and giveaways. Check the webhook event reference before subscribing a webhook.

Stream events

EventDescription
stream.onlineStream goes live
stream.offlineStream ends
stream.updateStream title, category or tags change

Channel events

EventDescription
channel.followSomeone follows the channel
channel.subscribeNew or renewed subscription
channel.subscription.giftGift subscription(s) given
channel.voltsUser sends Volts
channel.raidChannel receives a raid
channel.banUser banned from channel
channel.unbanUser unbanned from channel
channel.moderator.addModerator added
channel.moderator.removeModerator removed
channel.channel_points_redemptionChannel points reward redeemed

Chat events

EventDescription
chat.messageChat message sent in your channel (includes regular messages and card messages like stickers/sounds)

Interaction events

EventDescription
channel.poll.beginPoll starts
channel.poll.endPoll ends
channel.giveaway.beginGiveaway opens for entries
channel.giveaway.endGiveaway closes or is cancelled
channel.prediction.beginPrediction opens for bets
channel.prediction.lockPrediction stops taking bets (manually or when its timer runs out)
channel.prediction.endPrediction resolved (points paid out) or cancelled (refunded)
channel.goal.progressA goal's value changes
channel.goal.completedA goal reaches its target (once per period)
channel.timer.updateA timer or subathon starts, pauses, resumes, ends, resets, or gains or loses time

Event payload shapes

Every event arrives inside the same envelope — { event, timestamp, data }. The fields below live inside data (i.e. payload.data in the SSE examples above). Where a field is marked optional it may be absent on some events; test-fired events also carry isTest: true.

chat.message

A chat message. When the message starts with ! we pre-parse isCommand, command, and rawInput for you.

chat.message · data
{
  "user": "cosmicdragon357",
  "userId": "e08602f5-92f1-4231-8453-2737ecf1718a",
  "displayName": "CosmicDragon357",
  "message": "!so captainfrostbeak",
  "msgId": "a1b2c3d4-...",
  "badges": ["subscriber", "moderator"],
  "isMod": true,
  "isVip": false,
  "isSub": true,
  "subTier": 1,
  "isFirstMessage": false,
  "color": "#7B2FF7",
  "isCommand": true,
  "command": "so",
  "rawInput": "captainfrostbeak"
}
FieldTypeDescription
messageIdstringUnique message identifier
userIdstringSender's user ID
usernamestringSender's username
displayNamestringSender's display name
messagestringThe message text content
badgesstring[]Badge slugs for this user
isModbooleanWhether the user is a moderator
isVipbooleanWhether the user is a VIP
isSubscriberbooleanWhether the user is subscribed
subscriberMonthsnumber?Subscription tenure in months (if subscribed)
colorstring?User's accent color (hex)
mentionsadded 2026-03-02Array<{username, userId, displayName}>?Resolved @mentions found in the message. Only present when the message contains valid @username mentions.
cardadded 2026-03-02object?Present on card messages (stickers, sounds, celebrations). Contains type and payload.
isSystemboolean?True for system/card messages

Card types include: sticker-send, sound-send, emote-sticker-send, volts-celebration, subscription-celebration, gift-celebration, and more. Check data.card.type to identify the card type.

channel.follow

Someone follows the channel. avatarUrl is optional.

channel.follow · data
{
  "username": "cosmicdragon357",
  "displayName": "CosmicDragon357",
  "avatarUrl": "https://assets.velora.tv/avatars/..."
}

channel.subscribe

New subscription and resub both arrive here. tier is a string ("1" or "2"). months is present on resubs; treat a missing value as 1.

channel.subscribe · data
{
  "userId": "e08602f5-92f1-4231-8453-2737ecf1718a",
  "username": "cosmicdragon357",
  "displayName": "CosmicDragon357",
  "tier": "1",
  "months": 3
}

channel.subscription.gift

One or more gifted subs. Recipient userId fields may be null for anonymous/random recipients.

channel.subscription.gift · data
{
  "userId": "recipient-uuid-or-null",
  "gifterUserId": "gifter-uuid",
  "gifterUsername": "cosmicdragon357",
  "gifterDisplayName": "CosmicDragon357",
  "recipientUserId": "recipient-uuid-or-null",
  "recipientUsername": "captainfrostbeak",
  "recipientDisplayName": "CaptainFrostbeak",
  "quantity": 1,
  "tier": "1"
}

channel.volts

A viewer sends Volts. message is optional.

channel.volts · data
{
  "username": "cosmicdragon357",
  "displayName": "CosmicDragon357",
  "amount": 500,
  "message": "take my volts!"
}

channel.raid

The channel receives a raid.

channel.raid · data
{
  "fromUserId": "e08602f5-92f1-4231-8453-2737ecf1718a",
  "fromUsername": "cosmicdragon357",
  "fromDisplayName": "CosmicDragon357",
  "viewerCount": 42
}

channel.poll.begin / channel.poll.end

Both events carry the same full poll object — begin fires on creation, end when the poll closes or is cancelled. Read status to tell those apart (open | closed | cancelled). On begin the counts are zero and closedAt is absent; on end the counts hold the final tally. closesAt is present only if the creator set a timer. closedReasonis an optional free-text note (max 30 chars) the creator may supply when ending early — treat it as absent by default and never branch on its value. percentage is 0–100, rounded, and sums to ~100 across options. We do notemit an event per vote — that would fire on every ballot. Poll for live tallies if you need them.

channel.poll.end · data
{
  "id": "b7c1e2f0-...",
  "question": "Which boss next?",
  "status": "closed",
  "allowMultiple": false,
  "openedAt": "2026-08-01T22:14:03.000Z",
  "closesAt": "2026-08-01T22:16:03.000Z",
  "closedAt": "2026-08-01T22:16:03.000Z",
  "totalVotes": 42,
  "options": [
    { "id": "opt-1", "label": "Malenia",  "voteCount": 25, "percentage": 60 },
    { "id": "opt-2", "label": "Radahn",   "voteCount": 17, "percentage": 40 }
  ],
  "trigger": "closed"
}

channel.giveaway.begin / channel.giveaway.end

Same pattern: one full giveaway object on both events. begin fires when entries open, end when it closes or is cancelled. eligibility is all | followers | subscribers; entryMode is button | hashtag (hashtag is present only in that mode); drawState is pending | drawing | complete. winners is the full list and winner is the most recent draw (null if none yet) — expect both to be empty on begin. closedReason is an optional free-text note (max 30 chars), same as on polls; when the platform auto-closes after the final draw it is the literal string winner. Note that on end the draw may still be running: check drawState rather than assuming winners are final. Entries and individual draws do not emit events.

channel.giveaway.end · data
{
  "id": "3f9a44e1-...",
  "title": "Steam key drop",
  "description": "One key, followers only",
  "status": "closed",
  "eligibility": "followers",
  "entryMode": "button",
  "openedAt": "2026-08-01T22:30:00.000Z",
  "closesAt": "2026-08-01T22:40:00.000Z",
  "closedAt": "2026-08-01T22:40:00.000Z",
  "totalEntries": 213,
  "drawState": "complete",
  "winnerCount": 1,
  "winnersDrawn": 1,
  "winners": [
    {
      "id": "e08602f5-...",
      "username": "cosmicdragon357",
      "displayName": "CosmicDragon357",
      "avatarUrl": "https://assets.velora.tv/avatars/...",
      "drawOrder": 1,
      "drawnAt": "2026-08-01T22:40:01.000Z"
    }
  ],
  "winner": {
    "id": "e08602f5-...",
    "username": "cosmicdragon357",
    "displayName": "CosmicDragon357",
    "avatarUrl": "https://assets.velora.tv/avatars/...",
    "drawOrder": 1,
    "drawnAt": "2026-08-01T22:40:01.000Z"
  },
  "autoDraw": true,
  "trigger": "closed"
}

channel.prediction.begin / .lock / .end

One full prediction object on every event. status is open | locked | resolved | cancelled; on end, settlement says what happened (resolved, no_winners when nobody picked the winner and everyone was refunded, or cancelled) and winningOutcomeId names the winner. Amounts are channel points, never money. Individual bets do not emit events.

channel.prediction.end · data
{
  "id": "6c1b5a90-...",
  "channelId": "9a1f0c2e-...",
  "title": "Do we beat the boss first try?",
  "status": "resolved",
  "lockAt": "2026-09-30T20:05:00.000Z",
  "lockedAt": "2026-09-30T20:05:00.000Z",
  "settledAt": "2026-09-30T20:21:40.000Z",
  "winningOutcomeId": "b2d0...",
  "settlement": "resolved",
  "maxStake": 250000,
  "totalPoints": 48250,
  "totalBettors": 37,
  "outcomes": [
    { "id": "b2d0...", "label": "Yes", "position": 0, "totalPoints": 30100, "bettorCount": 22 },
    { "id": "f71c...", "label": "No",  "position": 1, "totalPoints": 18150, "bettorCount": 15 }
  ]
}

channel.goal.progress / channel.goal.completed

Fires for the goals a creator builds under Goals, Widgets & Overlays, whatever moved them: a follow, sub or Volts, a manual adjustment, a reset, or a refund taking credit back. progress is value / target (can exceed 1). completed fires once, when the goal first reaches its target in a period.

channel.goal.completed · data
{
  "widgetId": "a41e...",
  "name": "Sub goal",
  "occurredAt": "2026-09-30T20:40:02.000Z",
  "value": 50,
  "target": 50,
  "progress": 1,
  "completed": true,
  "periodStartedAt": "2026-09-30T00:00:00.000Z"
}

channel.timer.update

Fires when a timer or subathon changes, not while it simply counts down. change is the new status (running | paused | ended | idle | waiting) or time_added / time_removed, with the amount in secondsChanged. endsAt is the wall-clock end if nothing else changes.

channel.timer.update · data
{
  "widgetId": "c93d...",
  "name": "Subathon",
  "occurredAt": "2026-09-30T21:02:11.000Z",
  "change": "time_added",
  "mode": "subathon",
  "status": "running",
  "remainingSeconds": 10421,
  "endsAt": "2026-10-01T00:05:52.000Z",
  "elapsedSeconds": 3000,
  "addedSeconds": 1800,
  "totalSeconds": 13421,
  "cappedSeconds": 0,
  "endedAt": null,
  "secondsChanged": 300
}

Fetching channel point rewards

To build a bot that responds to channel point redemptions, you'll want to know what rewards a channel has configured. You can fetch them via REST API or directly through WebSocket.

Option 1: REST endpoint

Request
GET https://api.velora.tv/api/channel-points/{channelId}/items/with-built-in

No authentication required. This endpoint is public, so you can fetch rewards without an access token.

Response 200 · REST
{
  "items": [
    {
      "id": "reward_abc123",
      "name": "Highlight My Message",
      "cost": 500,
      "description": "Make your message stand out!",
      "iconUrl": "https://...",
      "builtInType": null,  // null = custom reward
      "enabled": true,
      "requiresModeratorApproval": false,
      "maxPerStream": null,
      "maxPerUserPerStream": 3
    },
    {
      "id": "reward_xyz789",
      "name": "First",
      "cost": 1,
      "description": "Be the first to claim this each stream!",
      "builtInType": "first",  // Built-in reward type
      "enabled": true
    }
  ]
}

Option 2: WebSocket request

If you're already connected via WebSocket, you can request the rewards list directly:

Request · socket.emit
// Request rewards over WebSocket. Pass your channelId (from the 'connected'
// payload) — without it the request returns an empty list.
socket.emit('channel:getRewards', { channelId: myChannelId });

// Listen for the response. The payload is an OBJECT with a 'rewards' array;
// each item matches the REST shape: { id, name, cost, description, iconUrl,
// enabled, builtInType }.
socket.on('channel:rewards', ({ rewards }) => {
  console.log('Channel rewards:', rewards);
  // [
  //   { id: '...', name: 'Highlight My Message', cost: 500, enabled: true, builtInType: null },
  //   { id: '...', name: 'First', cost: 1, enabled: true, builtInType: 'first' }
  // ]
});

// Handle errors
socket.on('error', (err) => {
  if (err.code === 'SERVICE_UNAVAILABLE') {
    // Channel points service not available
  }
});

The WebSocket channel:rewards response is an object with a rewards array. Each item matches the REST shape:

Response · channel:rewards
{
  "rewards": [
    {
      "id": "reward_abc123",
      "name": "Highlight My Message",
      "cost": 500,
      "description": "Make your message stand out!",
      "iconUrl": "https://...",
      "enabled": true,
      "builtInType": null
    },
    {
      "id": "reward_xyz789",
      "name": "First",
      "cost": 1,
      "description": "Be the first to claim this each stream!",
      "iconUrl": null,
      "enabled": true,
      "builtInType": "first"
    }
  ]
}

Matching redemptions to rewards

When you receive a channel.channel_points_redemption event, it includes the rewardId and rewardTitle. Match these against your fetched rewards list to trigger the appropriate action:

channel.channel_points_redemption · event
// Redemption event payload
{
  "event": "channel.channel_points_redemption",
  "timestamp": "2026-02-10T12:00:00Z",
  "data": {
    "redemptionId": "red_abc123",
    "rewardId": "reward_abc123",     // Match this to your rewards list
    "rewardTitle": "Highlight My Message",
    "rewardCost": 500,
    "userId": "user_xyz",
    "username": "viewer123",
    "displayName": "Viewer123",
    "userInput": "Check out my message!",  // If reward accepts input
    "status": "unfulfilled",
    "redeemedAt": "2026-02-10T12:00:00Z"
  }
}

Listen for it by name with socket.on('channel.channel_points_redemption', handler), or catch every event through the generic socket.on('event', ({ event, data }) => …) fan-out and switch on event. For backward compatibility the same redemption also fires under the legacy alias name channel_point_redeem— both names carry an identical payload, so listen for whichever you already have wired up.

Events API vs Webhooks

FeatureEvents APIWebhooks
Server requiredNoYes
Desktop app friendlyYesNo
Connection typePersistent (outbound)On-demand (inbound)
LatencyLower (real-time)Slightly higher
Retry handlingAutomatic reconnectServer retries
Best forStream tools, bots, desktop appsWeb servers, automation