Social connections, publishing & live status

Krunc connects creator accounts through OAuth, stores tokens encrypted at rest (AES-256-GCM, ENCRYPTION_KEY), and only requests the scopes each feature uses. A provider with blank credentials in .env is simply unavailable in the UI — nothing crashes.

How it fits together

PieceWhere
Provider implementations (SocialProvider interface)app/Providers/Social/*Provider.php
Registry + feature flagsapp/Providers/Social/SocialRegistry.php
Connect / callback / disconnect UI + stream destinationsapp/Modules/Social → /dashboard/social (Channels)
Composer, posts, calendar, publish workerapp/Modules/Publisher → /dashboard/publisher, /dashboard/posts, /dashboard/calendar
Live directory + pollingapp/Modules/Live → /live, /live/now.json, /dashboard/live

Redirect URI for every provider: https://krunc.com/social/callback/{provider} (provider keys: twitch, youtube, discord, kick, x, facebook, instagram, tiktok, reddit, linkedin).

Background work runs from php bin/worker (cron every minute with --once, or as a daemon):

  • publisher.due (60 s) — enqueues scheduled posts whose time has come (the publish job is also queued at schedule time; the scheduler is a safety net).
  • social.refresh_tokens (30 min) — refreshes tokens that expire within 30 minutes; accounts whose refresh fails are marked expired and must be reconnected.
  • live.poll (2 min) — checks Twitch / YouTube / KICK accounts, records live_streams, flips the creator profile live badge and fires stream.started / stream.ended automations.
  • live.eventsub_sync (10 min) — keeps Twitch EventSub subscriptions in step with connected accounts when the social.twitch_eventsub flag is on (see Twitch EventSub).

Feature flags

Insert a row in feature_flags to switch a provider off without removing credentials (e.g. while waiting for app review):

INSERT INTO feature_flags (`key`, enabled, description, updated_at) VALUES ('social.instagram', 0, 'Waiting for Meta app review', UTC_TIMESTAMP());
-- later
UPDATE feature_flags SET enabled = 1 WHERE `key` = 'social.instagram';

A missing row means "enabled if credentials exist". The exception is social.twitch_eventsub, which is off until a row enables it.

Per-platform status

ProviderEnv varsScopes requestedConnectLive statusPublishApproval / notes
TwitchTWITCH_CLIENT_ID/SECRETuser:read:email, channel:read:stream_key, channel:manage:broadcastworksworks (Helix streams)n/a (no post API)None. channel:read:stream_key is only for fetching the stream key — accounts connected before it was added must reconnect. Follower counts need moderator:read:followers (not requested).
YouTubeGOOGLE_CLIENT_ID/SECRETyoutube.readonly, youtube.upload, youtube.force-sslworksliveBroadcasts?broadcastStatus=active (verify on a real channel)video only, resumable videos.insert (verify)Google OAuth consent-screen verification required for youtube.upload outside test users. 10k quota units/day.
DiscordDISCORD_CLIENT_ID/SECRETidentify, guildsworks; also sets users.discord_id for role deliveryn/an/aNone.
KICKKICK_CLIENT_ID/SECRETuser:read, channel:read, streamkey:read, channel:write (PKCE)workspublic/v1/channels (stream.is_live, viewer_count, start_time, thumbnail, stream_title, category.name — verified against docs.kick.com 2026-09-24)n/aApp registered 2026-09-24; credentials in .env. Kick's public API has no follower count, so KICK shows 0 followers on audience cards.
XX_CLIENT_ID/SECRETtweet.read, tweet.write, users.read, offline.access (PKCE)worksn/atext only, POST /2/tweetsPaid/limited tier: Free tier ~1,500 posts/month/app. Media upload not implemented.
Facebook PagesMETA_APP_ID/SECRETpages_show_list, pages_manage_posts, pages_read_engagementworks (first managed Page)n/atext + single image (/feed, /photos)Meta App Review + Business verification before non-admin users can connect. Tokens are long-lived (~60 d), no refresh → reconnect.
InstagramMETA_APP_ID/SECRETinstagram_basic, instagram_content_publish, pages_show_listworks (IG professional account linked to a Page)n/aimage only (container → media_publish)App Review for instagram_content_publish; Business/Creator account required; image must be a public URL.
TikTokTIKTOK_CLIENT_KEY/SECRETuser.info.basic, video.publishworksn/avideo only via Content Posting API PULL_FROM_URL (verify)App audit required; unaudited apps post as SELF_ONLY; video URL domain must be verified in the TikTok developer portal.
RedditREDDIT_CLIENT_ID/SECRETidentity, submitworksn/aself/link post: line 1 r/subreddit, line 2 title, rest bodyRegister a "web app"; API access is rate-limited (100 req/min).
LinkedInLINKEDIN_CLIENT_ID/SECRETopenid, profile, w_member_socialworksn/atext only, POST /rest/posts (version 202405)"Share on LinkedIn" product must be added to the app; refresh tokens only for approved partners (60-day expiry otherwise). Organization posting needs Marketing Developer Platform.

"verify" = implemented from the official documentation but not yet exercised against a live account from this host. The provider surfaces the platform's error message verbatim if a call fails — nothing is faked as success.

Setting up each OAuth app

  1. Create the app on the platform's developer portal and add the redirect URI above. /admin/integrations shows the exact URI to paste, links to each platform's console and lists the scopes we request.
  2. Paste the client id/secret into /admin/integrations (platform admin). They are stored in platform_settings, encrypted with ENCRYPTION_KEY, and override .env — nobody needs shell access to switch a platform on. The .env variables still work as a fallback and as the way to seed a fresh install; clearing the stored values falls back to them. SocialRegistry::credentials() resolves the two.
  3. The provider card on /dashboard/social turns from unavailable to Connect immediately.
  4. Connect from /dashboard/social as a workspace staff member or higher. The account is attached to the workspace's first creator profile.
  5. For platforms that need review, keep the feature flag off until approval; you can still connect as an app tester.

What an unavailable platform says, and to whom

SocialRegistry::status() returns a code (ok | no_credentials | flagged | unknown) rather than a sentence, so each audience is sent somewhere it can act. Creator-facing pages never name a server file:

WhoNo credentialsFeature flag off
Workspace member"X is not switched on for Krunc yet" → prefilled /contact?topic=supportsame
Platform admin→ /admin/integrations#<provider>→ /admin/flags

Channels: accounts and stream destinations on one page

/dashboard/social is one card per platform holding both halves of "this is my YouTube channel":

  • Account (staff and above) — the OAuth connection. This is what drives the live badge, viewer counts for sponsors, publishing and Discord role delivery.
  • Streaming (admin and above) — the RTMP/RTMPS server URL + stream key for the same platform.

The two are independent: OAuth does not hand out stream keys (with one exception, below) and a stream key gives Krunc no ability to post or read stats. Platforms that only do one of the two still get a card — Discord, Reddit and LinkedIn are account-only; Trovo, GameChanger and Custom RTMP are stream-only.

/dashboard/social/destinations now 301s to this page; the mutation endpoints under it are unchanged and stay admin-only.

Stream destinations

Stored so they can be copied into OBS / Streamlabs (Settings → Stream → Service Custom…) or used by Krunc ingest to relay one stream to every enabled destination. On their own they are a secure address book, not a relay: without ingest, video goes from the encoder straight to each platform, and multistreaming needs a multi-output plugin or a relay service.

  • Stream keys are encrypted at rest (AES-256-GCM, ENCRYPTION_KEY); lists show only the last four characters. Reveal and Copy key fetch the key over an audited JSON POST (stream_destination.revealed in the audit log, last_revealed_at on the row) — the raw key is never rendered into page HTML.
  • Deleting a destination is a hard delete so the encrypted key does not linger. Rotate a leaked key on the platform first, then update it here.
  • Server URLs must be rtmp:// or rtmps://. Presets live in App\Modules\Social\Services\StreamPresets and come from each platform's own streaming-software instructions:
PlatformServer URL presetBackupWhere the key lives
Twitchrtmp://live.twitch.tv/app (auto-routes to nearest ingest; regional list at help.twitch.tv/s/twitch-ingest-recommendation)rtmps://live.twitch.tv:443/appCreator Dashboard → Settings → Stream
YouTubertmp://a.rtmp.youtube.com/live2 (RTMPS: rtmps://a.rtmps.youtube.com:443/live2)rtmp://b.rtmp.youtube.com/live2?backup=1YouTube Studio → Go live → Stream
KICKrtmps://fa723fc1b171.global-contribute.live-video.net/app — confirm against the URL shown next to your key—Creator Dashboard → Settings → Stream key
Facebookrtmps://live-api-s.facebook.com:443/rtmp/—Live Producer → Streaming software (enable persistent key)
Instagramrtmps://live-upload.instagram.com:443/rtmp/—Instagram desktop → Live → Streaming software (per-broadcast key)
Xblank — per-source URL from Media Studio → Producer—Media Studio → Producer → Sources
TikTokblank — shown under Go LIVE → Streaming software once the account has LIVE Studio access—same screen
Trovortmp://livepush.trovo.live/live/—Stream Settings
GameChangerblank — a new server URL per game, from the game on the team's schedule—GameChanger app → team → Schedule → the game → Stream
Custom RTMPblank—your server

Plan limit: limits.stream_destinations on the tenant (default 12).

Testing a stream key before you stream

Test key on a destination (or Test all keys in the page head) publishes two seconds of black video and silence to it and keeps the platform's answer: last_test_status (pending | ok | failed), last_test_note and last_test_at on stream_destinations. The card shows the verdict, and the go-live checklist counts verified keys rather than saved ones.

StreamTester::verdict() reads ffmpeg's exit code and output, measured against a throwaway local RTMP sink and a real platform ingest:

ffmpeg saysVerdict
exit 0, no outputthe platform accepted the key
Error opening output <url>: Input/output error (exit 251)connected, then the platform rejected the key — the usual symptom of a rotated or revoked key
… : Connection refused (exit 145)nothing is listening at that server URL
… : Connection timed out (exit 146)the server URL does not respond

The probe is one output with no onfail, so a rejection is an exit code rather than a line to hunt for — the opposite of the relay, which deliberately ignores a failing destination to keep the rest of the broadcast alive.

Why it is queued. proc_open and popen are disabled in this host's PHP-FPM pool, so nothing can spawn ffmpeg from a web request. POST …/test only marks the destinations pending and pushes ingest.test_destinations with a run_at a few seconds out, which also stops the sync queue driver running it inline; php bin/worker (cron, every minute) does the probing. The page polls …/destinations/tests.json while anything is pending: a result appears within ~3 s when the worker runs as a service (runbook → worker & scheduler), or within a minute on the cron fallback. The stored note never contains the URL, because the URL contains the key.

Fetching a stream key from the connected account

Providers that implement App\Providers\Social\IngestCapable can fill in their own destination from the OAuth connection, so the creator copies nothing. POST /dashboard/social/destinations/import/{provider} (admin, throttled) resolves the workspace's active account for that provider, asks the provider for its endpoint, and creates or updates the destination for that platform. The key is encrypted like any other and the action is audited as stream_destination.imported.

PlatformFetchScopeHow
YouTubeyes— (youtube.readonly, already requested)liveStreams.list(part=cdn,snippet&mine=true) returns cdn.ingestionInfo.streamName + ingestionAddress. Prefers the channel's default (persistent) RTMP stream.
Twitchyeschannel:read:stream_keyGET helix/streams/key?broadcaster_id=… returns the primary stream key. Twitch publishes one ingest host for everyone, so the server URL comes from the preset, not the API.
KICKyesstreamkey:readGET public/v1/channels returns stream.url + stream.key once the scope is granted. A bare contribute host is completed to rtmps://<host>:443/app.
Facebooknot built—POST /{page-id}/live_videos does return a stream URL, but it needs the publish_video permission and another round of Meta App Review.
Instagram, TikTok, X, Trovo, GameChangerno—No API exposes the stream key; it is always pasted from the platform's own dashboard.

A provider declares the scope it needs through IngestCapable::ingestScope(). An account connected before that scope existed is asked to reconnect on its card (StreamDestinationController::needsReconnect() compares it against the scopes the platform actually granted) rather than being handed the platform's 401. When the platform reports no scopes at all, the call is allowed to speak for itself.

Nothing is written when the platform has no RTMP stream configured yet, when the URL it returns is not rtmp(s)://, or when the call fails — the platform's own message is surfaced. An ingest URL of '' from a provider means "the platform has one published host": the caller completes it from StreamPresets. Implemented from each platform's API docs but not yet exercised against a live channel from this host.

The go-live checklist

App\Modules\Social\Services\GoLiveChecklist is the single source of truth for "is this workspace ready to

stream", computed from the workspace rather than from anything a user ticks off. Two screens read it, which is

why it is a service: /dashboard/setup renders every step, and the dashboard's Launch checklist shows one

row with the score and the next thing to do. They cannot drift.

Steps are either required (connected platform, destinations, stream title, ingest key, first relayed session) or

optional (OBS configured by Krunc, sponsorships, server-side overlay). Only the required ones count towards

ready, so a workspace that wants no sponsors and streams from a phone is not told forever that it is unfinished.

The dashboard also orders its checklist by creator_profiles.creator_type, the answer given at onboarding: a

streamer sees going live second (right after their profile) and is never asked for a game server, while a FiveM

server is asked for its server first. See DashboardController::checklist().

One stream title everywhere

/dashboard/social/golive, /dashboard/social/ingest and /dashboard/social/obs all carry the same card

(social::_streaminfo): a title, a game/category, and a switch for pushing them when you go live. One row per

workspace in stream_info, with the last push's per-platform outcome in last_results.

A provider opts in by implementing App\Providers\Social\BroadcastMetadata. What each platform actually takes,

from its own API reference:

PlatformCallScopeCategory
TwitchPATCH helix/channels (title, game_id)channel:manage:broadcastyes — the typed name is resolved through GET helix/games?name=; a name Twitch does not know is reported and the title still goes out
KICKPATCH public/v1/channels (stream_title, category_id)channel:writeyes — resolved through GET public/v2/categories?name= (the v1 ?q= search is deprecated)
YouTubeliveBroadcasts.update (snippet.title)youtube.force-sslno — snippet.categoryId is a video category (Gaming, Music…), not the game, so we do not map a game onto it

Notes that cost an outage if forgotten:

  • YouTube replaces the whole snippet part, so the existing scheduledStartTime is read and sent back with

the new title; leaving it out would move the broadcast. A channel with nothing live or scheduled has no title to

set, and says so rather than inventing a broadcast.

  • The scopes above are all new, so accounts connected earlier are shown as reconnect needed on the card and

are never called with a token that lacks the scope (SocialService::hasScope).

  • Every push is recorded per platform. A refusal, a missing scope and a thrown error each appear against that

platform's name — nothing is rolled into a single "done".

When it is pushed. Pressing Save & push now does it in the request. Going live does it too, if the switch is

on: the browser path pushes inline in POST /dashboard/social/golive/start (and the page reports any platform

that refused), while the RTMP path queues social.apply_stream_info from IngestService::startSession() — that

code runs inside MediaMTX's runOnOnline hook, where three platform API calls would hold the relay up.

Krunc ingest

Stream once from OBS to rtmp://ingest.krunc.com/live and Krunc relays it to every enabled stream destination. It is a stream-copy relay (no transcoding): bitrate, resolution and codecs pass straight through, so CPU cost is a few percent per stream. Outbound bandwidth is bitrate × destinations — 6 Mbps to three platforms is 18 Mbps out, roughly 10 GB per streamed hour.

How it works

  1. deploy/ingest/install.sh installs MediaMTX as a systemd service running as the app user, listening on TCP 1935 (RTMP) with a local-only API on 127.0.0.1:9997.
  2. Every MediaMTX action is authorised by POST /hooks/ingest/auth?secret=… (IngestService::authorize). Publishing needs a valid, enabled workspace ingest key (ingest_keys, stored hashed, shown once) on path live/<key>, an active workspace, and a free slot (INGEST_MAX_CONCURRENT). Reads and API calls are allowed from loopback only — nobody can watch the raw feed.
  3. When the stream comes online MediaMTX runs bin/ingest-hook-online (a tiny sh wrapper — MediaMTX execs hooks without a shell), which calls bin/ingest-relay start: it opens an ingest_sessions row (snapshot of the destinations), fires the stream.started automation trigger with source: ingest, decrypts the enabled destinations' keys and prints an exec ffmpeg … -c copy -f tee … line that MediaMTX evals. One ffmpeg per session pushes to all destinations; a failing destination is ignored (onfail=ignore) while the others continue.
  4. ffmpeg's output is piped into bin/ingest-relay watch (IngestService::watchRelay), which masks the last path segment of every RTMP/RTSP URL — and the workspace's own ingest path — before a line reaches storage/logs/ingest.log, so no key ever lands in a log file. See when a destination refuses the stream below. The watcher also ticks once a second whether or not ffmpeg said anything (stream_select, so it is not blocked in fgets) and copies MediaMTX's bytesReceived for the path onto the session every 10 s, with a final read before it exits — reconcile's minute tick never sees a short stream at all, which is why every session used to record 0 MB.
  5. When the publisher disconnects MediaMTX sends SIGINT to ffmpeg and runs bin/ingest-relay stop, which closes the session. The scheduler (ingest.reconcile, every minute) also closes sessions whose path is no longer publishing and records bytes received / peak kbps from the MediaMTX API, so a crash never leaves a slot occupied.

When a destination refuses the stream — onfail=ignore is what keeps one bad stream key from ending the whole broadcast, which also means it would otherwise be invisible until someone noticed the platform was dark. ffmpeg reports a destination it cannot publish to as [fifo @ …] Error opening <url>: <reason> (Input/output error for a key the platform rejected, Connection refused for an unreachable host). IngestService::parseRelayLine matches that URL against the session's own targets — the relay's input URL and a destination that was removed mid-session are deliberately not matched — and noteDestinationFailure then:

  • flips that entry in ingest_sessions.destinations to status: failed with the reason, and writes last_error (Twitch: Input/output error);
  • notifies the workspace's owners and admins once (stream.destination_failed, deep-linked to that platform's card). ffmpeg retries a broken slave, so the first reason wins and nothing is sent twice.

The ingest page then shows not receiving next to that destination, the browser go-live page shows the same warning while live, and the recent-sessions table marks it in red. Everything else keeps streaming. endSession() only ever adds a reason: a normal publisher disconnect passes none, and erasing last_error there used to wipe the note the moment the stream stopped.

The relay runs at -loglevel error, not warning: a real stream emits a per-frame Non-monotonic DTS warning (measured at ~90,000 log lines an hour), while the [fifo] Error opening <url> line the watcher parses is error-level. The watcher collapses runs of an identical line as a backstop.

When the relay itself dies — onfail=ignore saves the broadcast from one bad destination, but ffmpeg's tee muxer ends the whole process once its last slave gives up (All tee outputs failed). Seen for real while rehearsing a composited stream on this 2-core box: startup ran past the destination's read timeout, ffmpeg exited, and the publisher kept pushing video into a relay that no longer existed — session open, every destination still reading relaying, green LIVE badge, nothing on air.

The absence of the MediaMTX path is what separates this from the normal case, so IngestService asks the API who is still publishing:

  • the publisher is gone → an ordinary end; the stop hook closes the session and nothing is said;
  • the publisher is still there and the restart budget holds → the destinations read restarting, the page says Krunc is rebuilding the relay, and nobody is notified;
  • the budget is spent → destinations go to stopped, last_error says nothing is reaching them and how many rebuilds were tried, and the owners are notified (stream.relay_stopped).

bin/ingest-hook-online is what rebuilds it: when ffmpeg exits on its own the wrapper waits two seconds and runs bin/ingest-relay retry, which prints another ffmpeg … line or exit 0. IngestService::restartRelay() is the only decision point — the watcher only reports — and it re-checks everything, so a publisher that left between the two answers simply gets exit 0. A publisher disconnect sets a flag in the wrapper's SIGINT trap and ends the hook without asking at all.

  • Budget: MAX_RELAY_RESTARTS (3) within RESTART_WINDOW_SECONDS (300) — a rate limit, not a lifetime cap, so a five-hour stream that drops once an hour keeps being rescued while a relay failing every two seconds is given up on in about ten seconds. ingest_sessions.relay_restarts / relay_restarted_at hold the count; each rebuild is audited as ingest.relay_restarted.
  • Every destination gets another go. What kills a relay is the process, not one platform refusing one key, so a rebuild puts every entry back to relaying. A notified flag on the entry — not its status — is what keeps "told once" true, so a destination that fails again shows that again without a second notification per rebuild.
  • A composited session keeps its overlay clock. A new ffmpeg's filter clock starts at zero, which would replay the sponsor timeline from the top while impressions are still counted against the original stream clock. shiftPlan() moves each item's offset back by the seconds already streamed (reduced modulo the item's interval × count cycle, which leaves both terms of the enable expression alone). The shifted plan reaches ffmpeg only; overlay_plan — what advertisers are billed from — is never rewritten.
  • The alarm fires once even though both the watcher and restartRelay() reach the give-up: RELAY_STOPPED_NOTE is written and matched in one place. A rehearsal logged ingest.relay_stopped twice before this.

Dashboard — /dashboard/social/ingest (admin and above): generate or rotate the key (shown once, then only its last four characters), enable/disable, live status with destinations, recent sessions. Platform stream keys stay on the Channels page; the relay uses them server-side. Disabling a destination applies to the next session.

Configuration (.env): INGEST_HOST (blank disables the feature — the page explains how to use the destinations directly), INGEST_HOOK_SECRET (shared with mediamtx.yml), INGEST_MAX_CONCURRENT (default 3 — sized for this 2-vCPU VPS; the real limit is the plan's monthly transfer), INGEST_API_URL, INGEST_LOCAL_RTMP, INGEST_LOCAL_RTSP, INGEST_WHIP_URL, INGEST_MAX_COMPOSITED, INGEST_COMPOSITE_HEIGHT, INGEST_COMPOSITE_KBPS, OBS_WEBSOCKET_PORT.

DNS — Cloudflare cannot proxy RTMP. ingest.krunc.com must be a DNS-only (grey cloud) A record pointing at the VPS IP. Until it exists, OBS can use rtmp://149.28.95.15/live.

Security notes — keys are 24 random bytes, hashed with SHA-256; the auth hook is throttled per IP and refuses everything when no secret is configured. Platform keys are decrypted only inside the relay process and appear only in ffmpeg's process arguments (visible to local users — the box has no untrusted shell users). Rotate a destination's key on the platform if it leaks. RTMPS ingest (port 1936) and recording are deliberately out of scope for now.

Runbook — see runbook → Krunc ingest.

OBS setup

/dashboard/social/obs (admin+) removes the manual OBS configuration in two ways. Both hand over the workspace's existing ingest key, so a desktop and a laptop can both be set up and the zip can be downloaded afterwards without anything breaking:

  • Configure OBS on this computer — the page talks to the OBS WebSocket server (OBS 28+, Tools → WebSocket Server Settings; assets/js/obs-connect.js, obs-websocket protocol 5 with SHA-256 challenge auth) at ws://localhost:4455. It fetches a fresh key from POST /dashboard/social/obs/key, sets Settings → Stream to rtmp_custom with the Krunc server and key, adds (or updates) a locked browser source Krunc sponsor overlay with the profile's overlay URL sized to the canvas, and can start streaming. Nothing about OBS leaves the user's machine; browsers permit ws://localhost from an https page.
  • Download krunc-obs.zip — POST /dashboard/social/obs/profile.zip: an OBS profile folder (basic.ini 1080p30 / 4.5 Mbps x264 + service.json with server and key) and a scene collection Krunc-scenes.json with the overlay browser source, plus a README. Import both from the OBS menus.

Both accept rotate=1 to issue a new key instead, and both report rotated so the README and the page can say whether anything else needs updating. The key is only replaced without being asked when it cannot be read back — rows issued before migration 0028 store a hash and nothing else.

ingest_keys.key_enc (migration 0028) is why this works: the key is kept encrypted (AES-256-GCM under ENCRYPTION_KEY) beside its hash, which stays the lookup path for the auth hook. The same posture as stream_destinations.stream_key_enc, for the same reason — a key that has to reach an encoder cannot be write-only. POST /dashboard/social/ingest/reveal (admin, throttled) hands it back and writes ingest_key.revealed to the audit log, so losing it no longer means rotating every encoder.

Browser go-live (no software)

/dashboard/social/golive (admin+) captures the screen or camera with getDisplayMedia / getUserMedia and publishes to Krunc ingest over WebRTC / WHIP (assets/js/golive.js, H.264 preferred via setCodecPreferences, bitrate by quality preset).

  • POST /dashboard/social/golive/start mints a one-session browser token (ingest_keys.kind = browser, expires in 12 h, one live token per user); the tab publishes to path live/<token>.
  • The SDP exchange is proxied by the app: POST /whip/{token} forwards the offer to MediaMTX's WHIP endpoint on loopback (INGEST_WHIP_URL) and returns the answer with a Location of /whip/{token}/session/{id}; DELETE there ends the publish and disables the token. Both routes are session-authenticated, CSRF-checked and workspace-scoped (another workspace's member gets 404). Media itself flows browser → MediaMTX over UDP 8189 (webrtcLocalUDPAddress, webrtcAdditionalHosts = the public IP), so that port must be open.
  • MediaMTX fires the same runOnOnline hook; the relay reads the session over RTSP on loopback (INGEST_LOCAL_RTSP). ingest_sessions.kind records browser.
  • Browser sessions are re-encoded, not copied (ingest_sessions.transcoded = 1, migration 0031). The first real phone session (2026-10-12, iPhone Safari) arrived as 720p at 1–10 fps and ~130 kbps, then 360p at 0.4 fps — a WebRTC sender sheds frames and resolution the moment its uplink wobbles — and the -c:v copy relay wrote packets whose presentation time preceded their decode time on that input. The platforms did start the stream (the Twitch VOD of that session exists) but the picture moved in multi-second steps. The relay now regenerates timestamps (-fflags +genpts), fits the picture into the fixed 16:9 canvas (INGEST_COMPOSITE_HEIGHT) with OverlayCompositor::fitChain — scaled to fit and centred over a blurred, cropped-to-fill copy of itself, so a 9:16 phone stream shows soft colour at the sides on a 16:9 Twitch/Kick page instead of black bars, while a 16:9 input covers the canvas (the overlay path uses the same chain for its base; a rotation mid-stream reinitialises the graph without changing the output size) — pads holes in the audio with silence (IngestService::AUDIO_FILL, aresample=async=1: WebRTC audio arrives with gaps from lost packets and DTX, and a low-latency player such as Kick's stalls on every timestamp jump), forces a constant 30 fps by duplicating frames (-fps_mode cfr -r 30), and encodes with x264 at INGEST_COMPOSITE_KBPS with a keyframe every 2 s — the same encoder settings as the overlay path, and Opus → AAC. This takes an encode slot: IngestService::openComposited() counts composited and transcoded sessions together against INGEST_MAX_COMPOSITED; a composited browser session already encodes and needs no second slot. With no slot free the session is copied as before (-c:v copy -c:a aac), the log says ingest.transcode_skipped, and golive/status.json reports encode: copy (otherwise transcode / overlay) which the page shows next to the destinations.
  • Limits: browser encoders top out around 1080p30; system audio capture needs Chrome/Edge; closing the tab ends the stream. The page polls /dashboard/social/golive/status.json.
  • Uplink readout. While live the page reads the sender's own RTCPeerConnection.getStats() every 3 s (outbound-rtp video + its remote-inbound-rtp) and prints kbps, fps, frame size, loss and round trip under the preview (#gl-net). Under 400 kbps, under 10 fps or 3 %+ loss it turns amber and says the connection is holding the picture back, with qualityLimitationReason when the browser reports one. The same loop adapts the sender: while the readout shows under 10 fps and under 600 kbps it halves the frame size (RTCRtpEncodingParameters.scaleResolutionDownBy, one step per 10 s, down to ÷4, never in the first ~20 s while the bandwidth estimate ramps), and steps back up after 30 s of 25 fps+, 1.5 Mbps+ and under 1 % loss — a starved WebRTC encoder sheds frames before it sheds pixels, and a small moving picture beats a large frozen one. Note the relay duplicates frames only when the next source frame arrives, so a source at one frame per 4 s leaves the relay in 4-second bursts; Kick then reports the keyframe interval as wrong even though it is 2 s in media time. The cure is frames from the sender, which is what the downscale is for. Added after the first phone session arrived at 0.25 fps with 5 % loss and 100–370 ms jitter while the page simply said "live".
  • Camera step failures are spelled out. getUserMedia is raced against a 15 s timeout (iOS Safari can hang on it when another tab or app holds the camera); NotAllowedError explains where iOS keeps the per-site camera permission, NotReadableError/AbortError/timeout say to close other Krunc tabs and camera apps, OverconstrainedError retries once with plain {video: true, audio: true}. On a phone, a page that is not live releases the camera when it goes to the background (visibilitychange hidden / pagehide) and asks for Camera again on return, because iOS hands the camera to one page at a time and a second tab opened later would otherwise never get it — which is what "the Go live button is not enabled" looked like on the first phone session.
  • Relay delay, measured. Every relay runs with -progress pipe:2 -stats_period 10; the watcher turns each block's out_time_us into ingest_sessions.relay_lag_ms (wall-clock since that ffmpeg started minus the stream time it has muxed: startup analysis + buffering + encode), logs relay lag 1.2s speed 1.00x once a minute, and drops the progress lines themselves. golive/status.json carries relay_lag_ms and the page shows "relay delay 1.2 s" next to the destinations — Krunc's own share of what a viewer waits, separate from the platform's 5–30 s. The two encode paths read the publisher with -fflags +genpts+nobuffer -flags low_delay -probesize 1M -analyzeduration 1M -max_delay 200000 (RTSP over loopback TCP needs no reorder wait and the SDP already carries the codec parameters), which takes a few hundred ms off that figure. Twitch's documented ingest list (https://ingest.twitch.tv/ingests) has no Los Angeles entry; the generic live.twitch.tv resolves from this box to US West (Oregon), the nearest listed, so pinning an ingest gains nothing.
  • Side rails for a portrait picture. When the frame MediaMTX reports for a browser session is taller than wide (IngestService::sourceSize, from the H.264 SPS), the two bands beside the fitted picture become rails (OverlayCompositor::railFor: band width = (canvas − fitted width) / 2, needs 160 px of usable width, so 9:16 in 1280×720 gives 437 px rails with a 393 px panel). Each rail carries, top to bottom: the Krunc panel (renderRailPanel: wordmark, krunc.com/@handle, "scan to follow", and a QR of the profile URL drawn from App\Core\Qr's matrix — dark modules on a white tile so a phone camera reads it off a monitor), then sponsor cards when the overlay is on: plan() renders every creative at rail-card size (placement = rail, 16 % of the canvas height) and filterGraph() draws each card on both rails from one input (split) with the campaign's usual enable expression, cycling through the slots that fit under the panel. Impressions are counted exactly as before (same items, same slots). Without the overlay (transcoded session, no composite) railPlan() renders just the panel and stores rail + panel in overlay_plan beside the skipped reason, so a relay rebuild redraws it; session PNGs are removed at the end for transcoded sessions too. Landscape sources get no rails and no extra inputs. The rail is decided once at session start: a phone rotated mid-stream keeps the layout it started with (the page already says to settle on one orientation). The phone reports its orientation with the token request (orientation on POST /golive/start, stored in ingest_keys.orientation, migration 0033): portrait when the preview's frames are already taller than wide, landscape when the phone is sideways or it is a desktop, and portrait-unrotated when the phone is held upright but the camera hands over landscape frames — the server cannot tell that case from a landscape stream on its own (H.264 carries no orientation and MediaMTX does not negotiate WebRTC's rotation extension). For portrait-unrotated the relay turns the picture 90° clockwise (transpose=1 ahead of the fit chain, rotate stored in overlay_plan) and lays out the rails for the turned size. If a phone turns out to need the other direction, rotate takes ccw.
  • Phone mode (same page, same server side). The script treats a touch device with no getDisplayMedia (or a phone/tablet user agent) as a phone and adds gl-mobile to <body>: screen share is hidden, the quality presets drop to 2.5 / 1.2 Mbps for a Wi-Fi or cellular uplink (720p default; there is no 1080p capture on this page any more — the relay re-encodes at 720p, and a 1080p source pushed the encode past one core on this box, the relay ran at 0.91× real time and the measured relay delay climbed past 17 s), and the camera is asked for via facingMode (front by default). Flip switches front/back — on a desktop with several webcams it cycles them — by RTCRtpSender.replaceTrack on the open connection, so a switch mid-stream does not renegotiate or drop. Mute toggles the audio track's enabled flag. Portrait or landscape follows how the phone is held (matchMedia('(orientation: portrait)')): the capture asks for 9:16 or 16:9 to match, the preview flips to a 9:16 box, and the page tells the streamer to settle on one before going live because a resolution change mid-stream makes RTMP platforms stutter. A portrait stream is relayed as-is (-c:v copy); with the server-side overlay on it is pillarboxed into the 16:9 composite canvas. While live the page holds a screen wake lock (navigator.wakeLock, iOS 16.4+ / Android Chrome) and re-requests it when the tab becomes visible. When the OS takes the camera (a call, the lock screen, another app) the track ends; the page keeps the session and, as soon as the tab is visible again, reacquires the camera/mic and swaps the tracks in — the ingest session never ends, viewers see a frozen frame meanwhile. The front camera is previewed mirrored (.gl-mirror); the stream itself is sent unmirrored. iOS Safari and Android Chrome both send H.264, so the relay still copies video and only transcodes audio.

Streaming from a phone

Two paths. The first needs nothing installed: open /dashboard/social/golive in the phone's browser — it is the

browser go-live page in its phone mode (above): camera, flip, mute, portrait or landscape, screen kept awake. The

second is a dedicated app, which gives a hardware encoder, adaptive bitrate and background streaming the browser

cannot. Nothing special server-side for that — a phone is just another RTMP publisher on the same ingest key — but

typing a 35-character key on a touchscreen is the kind of friction that stops people. POST /dashboard/social/ingest/phone

(admin, throttled) returns the connection in three forms: server + key, a single rtmp_url for apps with one

field, and qr_svg, a QR of a Larix Grove deep link.

Grove is Softvelum's documented settings-import format for Larix Broadcaster:

larix://set/v1?conn[][url]=<url-encoded rtmp url>&conn[][name]=<label>. Scanning it with the phone's camera opens

Larix with the connection added. Only conn[][url] is required; encoder settings are left to the phone's defaults

rather than guessed at from here.

The QR is drawn by App\Core\Qr (the same encoder as sponsor tracking links — no third-party service sees a

stream key). The payload is ~120 bytes, well inside the encoder's ~210-byte ceiling; if a long enough ingest host

ever passed it, the QR is dropped and the manual fields still work. Because all three forms embed the key, the

endpoint is audited as ingest_key.revealed with via: phone, and the page renders none of them until asked.

Server-side sponsor overlay (burned in)

Switch on /dashboard/social/ingest or the go-live page (POST /dashboard/social/ingest/composite, stored on the workspace's RTMP key: composite, profile_id). When on, IngestService::startSession asks OverlayCompositor for a plan for that profile: every eligible campaign (SponsorService::eligibleCampaigns — consent, schedule, budget, frequency caps, same rules as the OBS overlay) and its approved creatives, up to 8. Each creative is rendered with GD into a transparent PNG the size of its placement box (banner / corner / lower third; artwork = creative image → video poster → sponsor logo; disclosure + sponsor, headline, CTA + tracked short link + promo code). The relay then runs one x264 encode (INGEST_COMPOSITE_HEIGHT 720p, INGEST_COMPOSITE_KBPS 3500, INGEST_COMPOSITE_PRESET ultrafast/zerolatency — measured on this 2-core VPS: ultrafast ≈ 0.75× realtime CPU for a 1080p30 source, veryfast is not realtime here) with an ffmpeg filter_complex where creative k of n for a campaign is enabled by the timeline expression lt(mod(t-offset,interval),display) * eq(mod(floor((t-offset)/interval),n),k) — the campaign's own cadence, deterministic, no scripting. Campaigns are staggered by offset so two never overlap.

  • Impressions — the plan is stored on the session (overlay_plan). IngestService::accountOverlay (every reconcile tick and at session end) computes which slots aired since overlay_accounted_at (OverlayCompositor::airedSlots) and inserts sponsor_impressions rows with the profile's live viewer count — the same rows and unique key the browser overlay uses, so reports, budgets and creator earnings are unchanged.
  • Capacity — INGEST_MAX_COMPOSITED (default 1: ~one CPU core per 720p30 encode on this 2-core box). When no slot is free, or the profile has sponsors off / no eligible campaign, the session is relayed untouched and overlay_plan.skipped says why (shown in the sessions table and the go-live status).
  • PNGs live in storage/ingest/overlays/<session>/ and are deleted when the session ends. Video creatives are shown through their poster frame (no video-in-video decode on the server).

Tests: tests/StudioTest.php (tokens, WHIP proxy, plan, filter graph, aired slots, accounting idempotency, capacity fallback, OBS payload and zip).

Your own AI graphics on the stream

/dashboard/social/graphics (admin+). Describe an overlay, Krunc generates it as a transparent cut-out at the exact size of its placement box, and the same compositor that burns in sponsors burns it into the broadcast — no browser source, no OBS plugin, nothing for the streamer to install. stream_graphics rows, StreamGraphics service, OverlayCompositor::graphicItems().

  • It is not a sponsor row. A sponsor creative belongs to a campaign with a budget, an approval state and an advertiser billed per impression; a streamer's own channel bug is none of those. Putting it in the sponsor tables would mean billing nobody for rows that look billable, or special-casing every sponsor query forever.
  • Nothing is billed for it. Workspace graphics carry no campaign_id, and airedSlots() skips items without one — so they ride in the same plan and the same filter graph while staying out of sponsor_impressions entirely. That is the line these tests exist to hold.
  • interval_seconds = 0 means always on: filterGraph() emits no enable= expression at all, so a channel bug does not blink. Anything higher uses the sponsor cadence expression.
  • Sponsors off still composites. plan() used to return early when a profile had sponsorships disabled, which meant a workspace that only wanted its own overlay got no composite at all. Sponsors are now a guarded block, not an early return.
  • Capped at StreamGraphics::MAX_PER_TENANT (4), inside the overall MAX_CREATIVES (8): every graphic is another ffmpeg input in a live encode on a 2-core box.
  • Artwork is copied into the session directory at plan time via StorageProvider::get(), not referenced in place, so it works when storage is S3 and so cleanup() can delete the session's files without touching the original the workspace paid to generate.
  • Changes apply to the next stream: the overlay plan is fixed when a session starts.

Verified end to end: a generated wolf emblem (384×187, 51% genuine alpha) composited at the corner box of real 1280×720 video with the compositor's own filter string, and airedSlots() returned 0 billable slots over a simulated two hours.

Impression accounting is anchored to started_at. overlay_accounted_at used to be a second now() written just after the session insert; when the two straddled a clock second, from became 1 instead of 0 and the slot at stream second 0 was never counted — an impression that aired and no advertiser was billed for. Found as an intermittent test failure, which is the only reason it was found at all.

Publishing rules

  • One post, many targets. Each target can carry its own text variant; the strictest character limit among selected accounts is shown live in the composer.
  • Hard limits enforced server-side: X 280, Instagram 2200, TikTok 2200, LinkedIn 3000, YouTube 5000, Facebook 63,206, Reddit title 300.
  • Media are public URLs (upload from the composer stores an image under /uploads/...). Instagram/TikTok/YouTube need media; X/LinkedIn are text-only for now.
  • Scheduled times are entered in the user's timezone (users.timezone) and stored in UTC.
  • Results are per platform: published, failed (with the platform's error, retry button), post status published / partial / failed.
  • post.published automation trigger fires when at least one target succeeds.

Live status

/live lists profiles with is_live = 1; category chips come from config('app.live_categories'). Platform categories are mapped (e.g. "Grand Theft Auto V" → "GTA RP"). A manual override from the profile editor (live_platform = manual) is never cleared by the poller.

Twitch EventSub (webhooks, behind a flag)

Twitch can push stream.online / stream.offline to https://krunc.com/webhooks/twitch within seconds instead of waiting for the 2-minute poll. It is off by default and gated by the feature flag social.twitch_eventsub (a missing row means off — unlike social.<provider> flags).

Enable

  1. Twitch credentials in .env (TWITCH_CLIENT_ID/SECRET) — the same app the creators connect with.
  2. TWITCH_EVENTSUB_SECRET in .env: 10–100 characters (php bin/keygen prints one). Twitch signs every message with it.
  3. APP_URL must be https:// — Twitch only calls HTTPS callbacks on port 443.
  4. Turn the flag on at /admin/flags (key social.twitch_eventsub), then either wait for the scheduler (live.eventsub_sync, every 10 min) or press Sync subscriptions on /dashboard/live.

How it works (App\Modules\Live\Services\EventSubService, App\Providers\Social\TwitchProvider implements EventSubCapable)

  • sync() keeps one subscription per (connected Twitch account, type) using an app access token (client credentials): POST helix/eventsub/subscriptions with transport = {method: webhook, callback, secret}, condition broadcaster_user_id. Rows live in eventsub_subscriptions (pending → enabled once Twitch's challenge is answered; failed rows retry hourly; revoked rows keep Twitch's reason in error). A 409 from Twitch is resolved by listing subscriptions and adopting the existing one, so a channel connected to several workspaces shares a single subscription (it is only deleted on Twitch when no workspace references it). Disconnecting an account, or turning the flag off, removes its subscriptions on the next sync.
  • Inbound: signature = sha256= HMAC-SHA256(secret, Twitch-Eventsub-Message-Id + Twitch-Eventsub-Message-Timestamp + raw body), compared with hash_equals; messages older than 10 minutes are rejected (403). webhook_callback_verification is answered with the raw challenge as text/plain. notification and revocation are stored in webhook_events (provider twitch, event id = message id, duplicate → 200 no-op) and processed by the twitch.process_event job — so Twitch gets its 2xx within milliseconds and failed events can be re-run from /admin/webhooks.
  • stream.online opens a live_streams row from the event (stream id, login, started_at), enriches it with title / category / thumbnail / viewers from Helix using the creator's token when that works, flips the profile live (stream.started automation) and samples the audience. stream.offline closes the stream and clears the badge unless another platform is still live or the profile is on the manual override.
  • The poller keeps running: it updates viewer counts and peak viewers and is the fallback if a notification is lost. Cost: each webhook subscription costs 1 point against Twitch's 10,000 default limit per app (cost is shown on the dashboard card).

YouTube offers PubSubHubbub for uploads but not live state, so YouTube and KICK stay on polling.