<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Maxwell Lin</title>
    <description>The latest articles on DEV Community by Maxwell Lin (@maxwell_lin_d59a2746f8a27).</description>
    <link>https://dev.to/maxwell_lin_d59a2746f8a27</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3079506%2Fa8e0d21c-dbc5-4b9d-bb37-40b3e051b1a2.png</url>
      <title>DEV Community: Maxwell Lin</title>
      <link>https://dev.to/maxwell_lin_d59a2746f8a27</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maxwell_lin_d59a2746f8a27"/>
    <language>en</language>
    <item>
      <title>Telehealth video visits with expiring links: one API call per appointment</title>
      <dc:creator>Maxwell Lin</dc:creator>
      <pubDate>Tue, 27 Oct 2026 07:29:15 +0000</pubDate>
      <link>https://dev.to/maxwell_lin_d59a2746f8a27/telehealth-video-visits-with-expiring-links-one-api-call-per-appointment-2nag</link>
      <guid>https://dev.to/maxwell_lin_d59a2746f8a27/telehealth-video-visits-with-expiring-links-one-api-call-per-appointment-2nag</guid>
      <description>&lt;p&gt;&lt;em&gt;From the Streaming CDN team. The original, kept up to date, is at &lt;a href="https://streaming-cdn.ewin888.com/guides/telehealth-video-visit-api-expiring-links" rel="noopener noreferrer"&gt;streaming-cdn.ewin888.com/guides/telehealth-video-visit-api-expiring-links&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A clinic or health platform usually wants the same thing for every appointment: a link the patient can open in a browser without installing anything, a different link for the clinician, both dead after the visit window, and a record of when the visit really started and ended. On Streaming CDN that is one server API call per appointment. This guide walks through it with curl and a webhook handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Create the visit from your backend
&lt;/h2&gt;

&lt;p&gt;Use a server API key with the &lt;code&gt;temporary-meetings:write&lt;/code&gt; scope. Keep the key on your server; the browser only ever receives the links.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://streaming-cdn.ewin888.com/v1/applications/temporary-meetings &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer scdn_your_server_key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "title": "Follow-up visit",
    "guestDisplayName": "Patient",
    "staffDisplayName": "Dr. Lee",
    "mode": "face_to_face",
    "recordingEnabled": false,
    "expiresInMinutes": 60,
    "callbackUrl": "https://clinic.example.com/webhooks/visit",
    "metadata": { "appointmentId": "apt_4821" }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Values&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1–120 characters&lt;/td&gt;
&lt;td&gt;Shown to your staff, not to the patient&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;guestDisplayName&lt;/code&gt; / &lt;code&gt;staffDisplayName&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1–90 characters&lt;/td&gt;
&lt;td&gt;Defaults &lt;code&gt;Guest&lt;/code&gt; and &lt;code&gt;Staff&lt;/code&gt;; a second join on the same link gets a numeric suffix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;face_to_face&lt;/code&gt; or &lt;code&gt;observe_guest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;observe_guest&lt;/code&gt; starts the staff side with camera and microphone off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;recordingEnabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; / &lt;code&gt;false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Records automatically when the visit begins. Default &lt;code&gt;false&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expiresInMinutes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5–1440&lt;/td&gt;
&lt;td&gt;Validity of both links. Default 30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;callbackUrl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Public HTTPS URL&lt;/td&gt;
&lt;td&gt;Receives signed lifecycle events. Omit it to poll instead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;metadata&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JSON object&lt;/td&gt;
&lt;td&gt;Your own ids, returned unchanged in queries and webhooks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The response contains two links and, when you passed &lt;code&gt;callbackUrl&lt;/code&gt;, a secret that is shown only this once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meeting"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"temporary_meeting_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"face_to_face"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"joinCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"expiresAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-12T10:00:00.000Z"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"guestUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../temporary-meeting/tm_guest_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"staffUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://.../temporary-meeting/tm_staff_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"callbackSecret"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tmsec_store_this_once"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Send each link to the right person
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;guestUrl&lt;/code&gt; goes to the patient, by SMS, email or your patient portal. It opens a browser page with camera and microphone; there is no app to install.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;staffUrl&lt;/code&gt; stays inside your clinician tools. It joins with the host role, so never send it to a patient.&lt;/li&gt;
&lt;li&gt;The links are bearer tokens: anyone holding one can join until it expires. Store &lt;code&gt;meeting.id&lt;/code&gt;, not the URLs, in your appointment record.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Expiry, rotation and revocation
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Patient lost the link, or it was sent to the wrong address&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /v1/applications/temporary-meetings/{id}/links/guest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A new guest link; the old one stops working immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replace the clinician link&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /v1/applications/temporary-meetings/{id}/links/staff&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same, for the staff link&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Appointment cancelled&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DELETE /v1/applications/temporary-meetings/{id}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The room ends and both links are revoked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check where a visit stands&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /v1/applications/temporary-meetings/{id}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Status, expiry, join count, start and end times&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A visit moves through &lt;code&gt;active&lt;/code&gt; → &lt;code&gt;in_use&lt;/code&gt; → &lt;code&gt;completed&lt;/code&gt;, or ends as &lt;code&gt;expired&lt;/code&gt; or &lt;code&gt;revoked&lt;/code&gt;. Opening a link after the visit has expired, ended or been revoked returns &lt;code&gt;410 temporary_meeting_unavailable&lt;/code&gt;, and rotating a link on a finished visit returns &lt;code&gt;409&lt;/code&gt;; show the patient a "this visit link is no longer valid" page and, if appropriate, create a new visit.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Know when the visit really happened
&lt;/h2&gt;

&lt;p&gt;The visit counts as started only when at least one patient and one clinician are online together, so a patient waiting alone does not start the clock. Your &lt;code&gt;callbackUrl&lt;/code&gt; receives &lt;code&gt;temporary_meeting.started&lt;/code&gt; and then &lt;code&gt;temporary_meeting.ended&lt;/code&gt; with &lt;code&gt;startedAt&lt;/code&gt;, &lt;code&gt;endedAt&lt;/code&gt;, &lt;code&gt;durationSeconds&lt;/code&gt;, &lt;code&gt;endReason&lt;/code&gt;, &lt;code&gt;participantPeak&lt;/code&gt; and your &lt;code&gt;metadata&lt;/code&gt;. Each event has a stable &lt;code&gt;id&lt;/code&gt;; use it to ignore duplicates.&lt;/p&gt;

&lt;p&gt;Verify every webhook before trusting it. The signature is an HMAC-SHA256 of &lt;code&gt;timestamp + "." + rawBody&lt;/code&gt; with the &lt;code&gt;callbackSecret&lt;/code&gt;, base64url-encoded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleVisitWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callbackSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-streaming-cdn-timestamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supplied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-streaming-cdn-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;v1=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callbackSecret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supplied&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;supplied&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// event.type: "temporary_meeting.started" | "temporary_meeting.ended"&lt;/span&gt;
  &lt;span class="c1"&gt;// event.metadata.appointmentId identifies your appointment&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Answer with any 2xx status. Failed deliveries are retried, so the handler should be idempotent on the event &lt;code&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Recording, when you need it
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;recordingEnabled: true&lt;/code&gt; the visit is recorded from the moment it begins. Recordings are kept on the platform and can be listed with &lt;code&gt;GET /v1/rtc/recordings?roomId=&amp;lt;meeting.roomId&amp;gt;&lt;/code&gt; and downloaded with &lt;code&gt;GET /v1/rtc/recordings/{id}/download&lt;/code&gt;, using a server API key. Decide per appointment type whether to record; many visits should not be.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Optional: confirm a real person first
&lt;/h2&gt;

&lt;p&gt;For visits where identity matters, create a liveness check with &lt;code&gt;POST /v1/applications/liveness/sessions&lt;/code&gt; and send its verification link before the visit link. The patient follows on-screen actions (blink, shake the head, nod) in the browser and your backend receives a signed &lt;code&gt;liveness.completed&lt;/code&gt; result; only then send &lt;code&gt;guestUrl&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays with you
&lt;/h2&gt;

&lt;p&gt;The platform provides the video, the links, the verification and the recording. Medical records, consent, what you record and how long you keep it remain the healthcare provider's responsibility under local regulations. Put only non-identifying ids such as an appointment id in &lt;code&gt;title&lt;/code&gt; and &lt;code&gt;metadata&lt;/code&gt;, not diagnoses or patient names.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Streaming CDN runs one-to-one video visits from your backend: separate patient and clinician links that expire, joined from a browser with no app. &lt;a href="https://streaming-cdn.ewin888.com/products/telehealth" rel="noopener noreferrer"&gt;Learn more&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>security</category>
    </item>
    <item>
      <title>White-label live streaming for resellers: per-customer domains, isolation and usage billing</title>
      <dc:creator>Maxwell Lin</dc:creator>
      <pubDate>Tue, 27 Oct 2026 07:27:06 +0000</pubDate>
      <link>https://dev.to/maxwell_lin_d59a2746f8a27/white-label-live-streaming-for-resellers-per-customer-domains-isolation-and-usage-billing-4abg</link>
      <guid>https://dev.to/maxwell_lin_d59a2746f8a27/white-label-live-streaming-for-resellers-per-customer-domains-isolation-and-usage-billing-4abg</guid>
      <description>&lt;p&gt;&lt;em&gt;From the Streaming CDN team. The original, kept up to date, is at &lt;a href="https://streaming-cdn.ewin888.com/guides/white-label-live-streaming-reseller-domains" rel="noopener noreferrer"&gt;streaming-cdn.ewin888.com/guides/white-label-live-streaming-reseller-domains&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A reseller, agency or group brand that runs live video for many customers needs each of them to look and behave like a separate service: their own domain in the player URL, their own stream names, their own protection rules, and a bill that matches what they actually used. This guide shows how Streaming CDN models that, and what the setup looks like for one new customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: one workspace per customer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Per customer&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Workspace&lt;/td&gt;
&lt;td&gt;Its own console login and settings; one customer never sees another's streams or numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ingest domains&lt;/td&gt;
&lt;td&gt;Where encoders publish, for example &lt;code&gt;push.customer-a.com&lt;/code&gt;. Each ingest domain is its own stream namespace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playback domains&lt;/td&gt;
&lt;td&gt;Where viewers watch, for example &lt;code&gt;live.customer-a.com&lt;/code&gt;. Each playback domain is bound to one ingest domain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protection&lt;/td&gt;
&lt;td&gt;Stream Keys, publisher IP allowlists, signed playback URLs, Referer/Origin allowlists and request-source policies, set per domain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usage ledger&lt;/td&gt;
&lt;td&gt;Traffic measured per customer, stream and domain, as the basis for billing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Stream names never collide
&lt;/h2&gt;

&lt;p&gt;Every ingest domain is an independent namespace, so two customers can both publish a stream called &lt;code&gt;main&lt;/code&gt; without touching each other. A customer with several ingest domains, for example one per event or per brand, gets one namespace per domain as well.&lt;/p&gt;

&lt;p&gt;A playback domain plays the streams of the ingest domain it is bound to. Several playback domains can be bound to the same ingest domain, for example one per partner site, each with its own allowlist and policy, and a playback domain can be re-bound later without changing the encoder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer A
  push.customer-a.com   (ingest)    -&amp;gt;  stream "main"
  live.customer-a.com   (playback, bound to push.customer-a.com)

Customer B
  push.customer-b.com   (ingest)    -&amp;gt;  stream "main"   (a different stream)
  watch.customer-b.com  (playback, bound to push.customer-b.com)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Domains on the customer's own DNS
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Add the domain in the customer's workspace and choose ingest or playback (a playback domain also picks its ingest domain).&lt;/li&gt;
&lt;li&gt;The console shows one CNAME record. The customer, or you on their behalf, creates it at their DNS provider.&lt;/li&gt;
&lt;li&gt;The platform issues and renews the TLS certificate. The console shows the domain's DNS and certificate status, and the domain becomes usable only when both are active, so a half-configured domain never serves viewers.&lt;/li&gt;
&lt;li&gt;Publish from OBS over WHIP to &lt;code&gt;https://push.customer-a.com/live/main&lt;/code&gt; and watch at &lt;code&gt;https://live.customer-a.com/live/main&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Browsers only allow camera and microphone on secure origins, which is one more reason the certificate has to be active before a domain is used for publishing from a web page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protection per customer
&lt;/h2&gt;

&lt;p&gt;Because protection is set on each domain, a customer that embeds the player on one site can lock playback to that site's origin, while another customer that sells tickets can require signed playback URLs. Stream Keys are created per ingest domain and shown once; publisher IP allowlists stop anyone else from publishing into the customer's namespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Billing by actual usage
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Usage is recorded per customer, per stream and per domain, so a customer's bill can be broken down the same way.&lt;/li&gt;
&lt;li&gt;A reseller account sees all of its customers, opens new customer workspaces with just an email address, and gets a monthly statement of its customers' usage for settlement.&lt;/li&gt;
&lt;li&gt;Customers under a reseller pay through the reseller, not by card on the platform, so the commercial relationship stays with you.&lt;/li&gt;
&lt;li&gt;Request logs record every playback and publish and export as CSV, which helps answer a customer's "why is my bill higher this month".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist for a new customer
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Create the customer's workspace with their admin's email address.&lt;/li&gt;
&lt;li&gt;Add an ingest domain and a playback domain bound to it; have the customer create the two CNAME records.&lt;/li&gt;
&lt;li&gt;Wait until both domains show DNS and certificate active.&lt;/li&gt;
&lt;li&gt;Turn on Stream Key authentication and create a key for the encoder; add the publisher's public IP if it is fixed.&lt;/li&gt;
&lt;li&gt;Set the playback protection the customer needs: allowed origins, signed URLs, request-source policy (start in log mode).&lt;/li&gt;
&lt;li&gt;Publish a test stream and confirm it plays on the customer's playback domain and appears in their usage.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Streaming CDN delivers live video to browsers over WebRTC, with chat and votes on real-time data channels, adaptive quality, recording and playback protection. &lt;a href="https://streaming-cdn.ewin888.com/products/live-streaming" rel="noopener noreferrer"&gt;Learn more&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>livestreaming</category>
      <category>webrtc</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>WebRTC vs HLS for live streaming: latency, interaction and when to use each</title>
      <dc:creator>Maxwell Lin</dc:creator>
      <pubDate>Wed, 07 Oct 2026 06:28:55 +0000</pubDate>
      <link>https://dev.to/maxwell_lin_d59a2746f8a27/webrtc-vs-hls-for-live-streaming-latency-interaction-and-when-to-use-each-1cd8</link>
      <guid>https://dev.to/maxwell_lin_d59a2746f8a27/webrtc-vs-hls-for-live-streaming-latency-interaction-and-when-to-use-each-1cd8</guid>
      <description>&lt;p&gt;&lt;em&gt;From the Streaming CDN team. The original, kept up to date, is at &lt;a href="https://streaming-cdn.ewin888.com/guides/webrtc-vs-hls-live-streaming-latency" rel="noopener noreferrer"&gt;streaming-cdn.ewin888.com/guides/webrtc-vs-hls-live-streaming-latency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;HLS and DASH cut a live stream into segments of a few seconds and the player buffers several of them before it starts, so viewers usually watch several seconds or more behind the camera. Low-latency variants shrink that, but the design is still built on files and buffers. WebRTC sends media continuously as packets over a real-time connection, so end-to-end delay is measured in hundreds of milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;HLS / DASH&lt;/th&gt;
&lt;th&gt;WebRTC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Typical delay&lt;/td&gt;
&lt;td&gt;Several seconds and more&lt;/td&gt;
&lt;td&gt;Low hundreds of milliseconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How media travels&lt;/td&gt;
&lt;td&gt;Segment files over HTTP, buffered&lt;/td&gt;
&lt;td&gt;Continuous packets over a real-time connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser playback&lt;/td&gt;
&lt;td&gt;Through a player library (native in Safari)&lt;/td&gt;
&lt;td&gt;Native in every modern browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interaction in the same moment&lt;/td&gt;
&lt;td&gt;Viewers react to what happened seconds ago&lt;/td&gt;
&lt;td&gt;Host and viewers interact in the same second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weak networks&lt;/td&gt;
&lt;td&gt;Switches renditions between segments&lt;/td&gt;
&lt;td&gt;Simulcast: several quality levels at once, each viewer gets what their line can carry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When the seconds matter
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live chat, Q&amp;amp;A and votes: a host answering a question that is already ten seconds old breaks the conversation.&lt;/li&gt;
&lt;li&gt;Live commerce and auctions: bids and flash-sale stock have to match the picture.&lt;/li&gt;
&lt;li&gt;Sports and esports: with in-play predictions or bets, a viewer who is seconds behind learns the result from a faster source first.&lt;/li&gt;
&lt;li&gt;Remote monitoring: operators watching vehicles, tables or sites need the picture as it is now.&lt;/li&gt;
&lt;li&gt;Classes and coaching: a teacher's question and a student's answer must meet in real time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When HLS is still fine
&lt;/h2&gt;

&lt;p&gt;One-way broadcasts where nobody interacts, and on-demand video, do not gain much from shaving seconds; plain HTTP delivery and long caches work well there.&lt;/p&gt;

&lt;h2&gt;
  
  
  WebRTC delivery without building it
&lt;/h2&gt;

&lt;p&gt;Running WebRTC at scale means media servers, TURN relays, simulcast, reconnects and a delivery network close to viewers. Streaming CDN does that part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Publish from OBS over WHIP, RTMP or SRT encoders, or relay an existing RTSP, RTMP or HTTP-FLV stream.&lt;/li&gt;
&lt;li&gt;Viewers watch over WebRTC in the browser, through a network in more than 130 countries and territories, with nothing to install.&lt;/li&gt;
&lt;li&gt;Chat, votes and live data travel on real-time data channels in step with the picture.&lt;/li&gt;
&lt;li&gt;Signed playback URLs, Referer/Origin allowlists and request-source policies keep the stream on your own pages.&lt;/li&gt;
&lt;li&gt;Usage is measured per customer, stream and domain for billing.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Streaming CDN delivers live video to browsers over WebRTC, with chat and votes on real-time data channels, adaptive quality, recording and playback protection. &lt;a href="https://streaming-cdn.ewin888.com/products/live-streaming" rel="noopener noreferrer"&gt;Learn more&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>livestreaming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Video calls in React Native and Expo that ring a locked phone: push wake-up, CallKit and full-screen Android calls</title>
      <dc:creator>Maxwell Lin</dc:creator>
      <pubDate>Wed, 07 Oct 2026 06:28:34 +0000</pubDate>
      <link>https://dev.to/maxwell_lin_d59a2746f8a27/native-incoming-calls-in-react-native-callkit-on-ios-full-screen-calls-on-android-671</link>
      <guid>https://dev.to/maxwell_lin_d59a2746f8a27/native-incoming-calls-in-react-native-callkit-on-ios-full-screen-calls-on-android-671</guid>
      <description>&lt;p&gt;&lt;em&gt;From the Streaming CDN team. The original, kept up to date, is at &lt;a href="https://streaming-cdn.ewin888.com/guides/react-native-incoming-call-screen" rel="noopener noreferrer"&gt;streaming-cdn.ewin888.com/guides/react-native-incoming-call-screen&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A calling app has to ring when it is not running: on a locked iPhone, on an Android phone in a pocket, after the system has reclaimed the process. That takes a push that wakes the app and a system incoming-call screen, on each platform in its own way. This guide shows how the Streaming CDN calling SDK does it for React Native and Expo.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a call reaches a sleeping phone
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The caller starts a call; the call service sends a push with the call id, caller and mode (audio or video).&lt;/li&gt;
&lt;li&gt;iOS: a VoIP push (PushKit) wakes the app, which reports the call to CallKit. Android: an FCM message wakes the app, which shows a full-screen incoming-call notification.&lt;/li&gt;
&lt;li&gt;The person answers from the lock screen. The app accepts the call with its client token.&lt;/li&gt;
&lt;li&gt;The service makes the accept atomic — the first device to answer wins — and returns the media credential to that device only. Every other device signed in as the same person stops ringing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The push itself carries no media token and no server key; the media credential is returned only after the accept succeeds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"callId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rtc_call_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"callerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"callerName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Alex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"video"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expiresAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-26T04:00:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @streaming-cdn/rtc-react-native react-native-webrtc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Bare React Native apps rebuild the native project as usual.&lt;/li&gt;
&lt;li&gt;Expo apps need a development build or EAS Build (see the next section).&lt;/li&gt;
&lt;li&gt;Upgrading from a version without the native call layer means rebuilding the native project; until then native calls report that the module is unavailable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Expo: what changes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Install the SDK and the native WebRTC runtime with &lt;code&gt;npx expo install @streaming-cdn/rtc-react-native react-native-webrtc&lt;/code&gt; (or npm).&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;"@streaming-cdn/rtc-react-native"&lt;/code&gt; to &lt;code&gt;plugins&lt;/code&gt; in &lt;code&gt;app.json&lt;/code&gt; / &lt;code&gt;app.config.js&lt;/code&gt;; the plugin writes the camera and microphone usage descriptions.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;npx expo prebuild&lt;/code&gt;, then make a development build (&lt;code&gt;eas build --profile development&lt;/code&gt;, or &lt;code&gt;npx expo run:ios&lt;/code&gt; / &lt;code&gt;npx expo run:android&lt;/code&gt;). Expo Go cannot load a native WebRTC runtime, so calls do not work there.&lt;/li&gt;
&lt;li&gt;Test push wake-up on a real device: the iOS simulator does not receive VoIP pushes, and the Android full-screen permission below should be checked on the phones your users actually have.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@streaming-cdn/rtc-react-native"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Register each device for push wake-up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Your backend issues a client token per signed-in user and device. The &lt;code&gt;deviceId&lt;/code&gt; must identify the installation, never the person: the service tells the answering user's other devices to stop ringing by comparing device ids.&lt;/li&gt;
&lt;li&gt;Register the device for managed push only if you want the service to wake the app: iOS registers its PushKit VoIP token (64 hex characters), Android registers its FCM token. A registration whose platform does not match the client token is refused with &lt;code&gt;400 push_token_platform_mismatch&lt;/code&gt; instead of being stored and failing silently later.&lt;/li&gt;
&lt;li&gt;Tokens the push provider later reports as invalid are marked and skipped on the next call.&lt;/li&gt;
&lt;li&gt;Already have your own push or WebSocket channel? Deliver the incoming-call payload through it and hand it to the SDK; managed push is optional.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Join a call
&lt;/h2&gt;

&lt;p&gt;Your backend keeps the server API key and hands the app a short-lived credential:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ReactNativeRtcClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@streaming-cdn/rtc-react-native&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://your-backend.example.com/api/rtc/identity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;include&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rtc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReactNativeRtcClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;publishAudio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;publishVideo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;receiveAudio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;receiveVideo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rtc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the native call layer gives you
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Platform behaviour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Locked-screen and background calls&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RtcNativeIncomingCalls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Android full-screen intent notification; iOS PushKit with CallKit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ringtone and ringback&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RtcCallTones&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Honours the Android ringer mode; on iOS CallKit owns the incoming ringtone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio output routing&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;client.setAudioOutput()&lt;/code&gt;, &lt;code&gt;RtcAudioRouting&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Earpiece, speaker and Bluetooth through Android &lt;code&gt;AudioManager&lt;/code&gt; and iOS &lt;code&gt;AVAudioSession&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On iOS a call identifier must be a UUID. On Android your app owns its Firebase registration and forwards payloads to the SDK, so Firebase stays an optional dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Android 14 trap: full-screen intent
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;USE_FULL_SCREEN_INTENT&lt;/code&gt; is what lets an incoming call take over the screen. Three facts to know before shipping:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Since Android 14 (API 34) it is a special app access, granted automatically only to apps whose core function is alarms or calls. Other apps start without it.&lt;/li&gt;
&lt;li&gt;Since 31 May 2024, Google Play requires a declaration for it in the Play Console (App content → Sensitive permissions). Submit it before review.&lt;/li&gt;
&lt;li&gt;When the grant is missing there is no error: the call silently becomes an ordinary heads-up notification that is easy to miss. A build can pass testing and still be effectively uncallable on real devices.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Check at runtime and guide the user before the first call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;caps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;RtcNativeIncomingCalls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getIncomingCallCapabilities&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// caps.presentation: "full-screen" | "notification" | "none"&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;presentation&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;full-screen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// explain why, then:&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;RtcNativeIncomingCalls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openFullScreenIntentSettings&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getIncomingCallCapabilities()&lt;/code&gt; also reports &lt;code&gt;notificationsEnabled&lt;/code&gt;; when it is false the call will not appear at all. On iOS the result is always full-screen, because CallKit owns presentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ringing ends on its own
&lt;/h2&gt;

&lt;p&gt;A ringing invitation is valid for 45 seconds and a call nobody answers is closed as missed. On Android the incoming-call notification also carries a system timeout (45 seconds by default, &lt;code&gt;ringTimeoutMs&lt;/code&gt; in the push payload, 10–120 seconds), so ringing stops even if the app process was reclaimed and no cancel can run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors worth handling
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;401&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The client identity token expired&lt;/td&gt;
&lt;td&gt;Refresh it through your backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;403&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This identity is not a recipient of the call&lt;/td&gt;
&lt;td&gt;Do not show the call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;409&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Another device answered, or the call stopped ringing&lt;/td&gt;
&lt;td&gt;End the incoming UI quietly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push after &lt;code&gt;expiresAt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The call is over&lt;/td&gt;
&lt;td&gt;Discard it without showing any UI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;&lt;em&gt;The Streaming CDN calling SDK brings voice and video calls with system incoming-call screens to iOS, Android, Web, React Native and Expo. &lt;a href="https://streaming-cdn.ewin888.com/products/rtc-sdk" rel="noopener noreferrer"&gt;Learn more&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>android</category>
      <category>ios</category>
      <category>webrtc</category>
    </item>
    <item>
      <title>Go live from OBS to the browser in under a second with WHIP</title>
      <dc:creator>Maxwell Lin</dc:creator>
      <pubDate>Wed, 07 Oct 2026 06:28:12 +0000</pubDate>
      <link>https://dev.to/maxwell_lin_d59a2746f8a27/go-live-from-obs-to-the-browser-in-under-a-second-with-whip-5bob</link>
      <guid>https://dev.to/maxwell_lin_d59a2746f8a27/go-live-from-obs-to-the-browser-in-under-a-second-with-whip-5bob</guid>
      <description>&lt;p&gt;&lt;em&gt;From the Streaming CDN team. The original, kept up to date, is at &lt;a href="https://streaming-cdn.ewin888.com/guides/obs-whip-webrtc-live-streaming" rel="noopener noreferrer"&gt;streaming-cdn.ewin888.com/guides/obs-whip-webrtc-live-streaming&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;WHIP (WebRTC-HTTP Ingestion Protocol) lets an encoder publish over WebRTC with a single HTTP request. OBS Studio supports it natively, so a stream can go from OBS to viewers' browsers over WebRTC end to end, in the low hundreds of milliseconds, instead of the several seconds that segment-based delivery such as HLS adds.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The publish URL
&lt;/h2&gt;

&lt;p&gt;OBS publishes to your ingest domain, a publish point and a stream name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://&amp;lt;ingest-hostname&amp;gt;/&amp;lt;publish-point&amp;gt;/&amp;lt;stream-name&amp;gt;

# example
https://push.example.com/live/camera-01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The publish point defaults to &lt;code&gt;/live&lt;/code&gt;; the value configured in the console is authoritative.&lt;/li&gt;
&lt;li&gt;The stream name is the last path segment and is case-sensitive. Streams published at the same time on one ingest domain need different names.&lt;/li&gt;
&lt;li&gt;Viewers watch through a playback domain bound to the ingest domain the stream was published to.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Before you publish
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The ingest domain has active DNS and TLS.&lt;/li&gt;
&lt;li&gt;If source IP allowlisting is on, add the publisher's public egress IP (a private LAN address is not enough).&lt;/li&gt;
&lt;li&gt;If Stream Key authentication is on, create a key for this exact ingest domain. The secret is shown once.&lt;/li&gt;
&lt;li&gt;Use a current OBS Studio release that includes WHIP.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3. OBS settings
&lt;/h2&gt;

&lt;p&gt;Open Settings → Stream and enter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;OBS field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Service&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHIP&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;The full WHIP URL, including publish point and stream name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bearer Token&lt;/td&gt;
&lt;td&gt;The ingest domain's Stream Key (empty only when Stream Key authentication is off)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;OBS sends the Bearer Token as &lt;code&gt;Authorization: Bearer &amp;lt;stream-key&amp;gt;&lt;/code&gt;. Treat Stream Keys as passwords: never put them in public URLs, screenshots, repositories or shared OBS profiles.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Recommended output for low latency
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Video encoder&lt;/td&gt;
&lt;td&gt;H.264 (hardware or software)&lt;/td&gt;
&lt;td&gt;Broad browser support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate control&lt;/td&gt;
&lt;td&gt;CBR&lt;/td&gt;
&lt;td&gt;Predictable bandwidth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;720p bitrate&lt;/td&gt;
&lt;td&gt;1.5–2.5 Mbps&lt;/td&gt;
&lt;td&gt;Adjust for motion and source quality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1080p bitrate&lt;/td&gt;
&lt;td&gt;3–6 Mbps&lt;/td&gt;
&lt;td&gt;Adjust for motion and source quality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyframe interval&lt;/td&gt;
&lt;td&gt;1 second&lt;/td&gt;
&lt;td&gt;Faster first frame and recovery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H.264 profile&lt;/td&gt;
&lt;td&gt;Main or Baseline&lt;/td&gt;
&lt;td&gt;Avoids unsupported decoder features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B-frames&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Lower latency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;Enabled, not muted&lt;/td&gt;
&lt;td&gt;WHIP negotiates the audio track&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  5. Check that it works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;OBS changes from Connecting to Live.&lt;/li&gt;
&lt;li&gt;The console's Live streams list shows the stream name with active tracks.&lt;/li&gt;
&lt;li&gt;Open &lt;code&gt;https://&amp;lt;playback-hostname&amp;gt;/&amp;lt;publish-point&amp;gt;/&amp;lt;stream-name&amp;gt;&lt;/code&gt; and check video and audio.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  6. When it does not work
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;401 invalid_stream_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Missing, expired, revoked or wrong-domain key&lt;/td&gt;
&lt;td&gt;Create a key for the active ingest domain and paste it into Bearer Token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;403 publisher_ip_denied&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The publisher's public IP is not allowed&lt;/td&gt;
&lt;td&gt;Add the real public egress IP, or turn the allowlist off while testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;429 Too Many Requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A duplicate publisher, or a concurrency limit&lt;/td&gt;
&lt;td&gt;Stop the old publisher or use a different stream name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OBS stays on Connecting&lt;/td&gt;
&lt;td&gt;Firewall, outbound UDP/DTLS, DNS or TLS&lt;/td&gt;
&lt;td&gt;Check DNS/TLS, allow outbound UDP, read the OBS log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live with no picture&lt;/td&gt;
&lt;td&gt;Hidden or muted source, incompatible output&lt;/td&gt;
&lt;td&gt;Check the OBS preview and mixer; use H.264 and a one-second keyframe interval&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Publishing from a web page instead
&lt;/h2&gt;

&lt;p&gt;A browser page can publish too: WHIP with &lt;code&gt;fetch()&lt;/code&gt; and a send-only &lt;code&gt;RTCPeerConnection&lt;/code&gt;. Because that request is cross-origin, list the page's origin under the ingest domain's browser publishing origins (CORS) in the console. Encoders such as OBS never ask for CORS and are unaffected.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Streaming CDN delivers live video to browsers over WebRTC, with chat and votes on real-time data channels, adaptive quality, recording and playback protection. &lt;a href="https://streaming-cdn.ewin888.com/products/live-streaming" rel="noopener noreferrer"&gt;Learn more&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>livestreaming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to stop other sites from re-streaming your live video</title>
      <dc:creator>Maxwell Lin</dc:creator>
      <pubDate>Wed, 07 Oct 2026 06:27:41 +0000</pubDate>
      <link>https://dev.to/maxwell_lin_d59a2746f8a27/how-to-stop-other-sites-from-re-streaming-your-live-video-ho</link>
      <guid>https://dev.to/maxwell_lin_d59a2746f8a27/how-to-stop-other-sites-from-re-streaming-your-live-video-ho</guid>
      <description>&lt;p&gt;&lt;em&gt;From the Streaming CDN team. The original, kept up to date, is at &lt;a href="https://streaming-cdn.ewin888.com/guides/stop-live-stream-hotlinking" rel="noopener noreferrer"&gt;streaming-cdn.ewin888.com/guides/stop-live-stream-hotlinking&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Once a live stream's URL is public, anyone can embed it on their own site, or run a scraper in a data center that re-streams it, and you pay for the traffic. No single setting stops every case; the answer is layers, each cheap to apply and each closing a different gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Signed playback URLs
&lt;/h2&gt;

&lt;p&gt;Your backend signs the playback URL with an expiry. A copied link stops working once it expires, so a URL pasted into a forum or another site is only good for a short time. Signed URLs only control who can start playback; a viewer who is already watching is not cut off.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Referer and Origin allowlists
&lt;/h2&gt;

&lt;p&gt;List the pages allowed to embed the player. Requests from any other site's page are refused, which stops the most common case: someone embedding your player or stream on their own page.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Request-source policies
&lt;/h2&gt;

&lt;p&gt;Real viewers come from home and mobile networks; most re-streaming scrapers run in data centers. A request-source policy classifies each request by the network it comes from and lets you log or refuse data-center sources per playback domain. Start in log mode, look at what it would have refused, then turn on refusal.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Address blocking
&lt;/h2&gt;

&lt;p&gt;When a specific source keeps pulling your stream, block its addresses. Request logs record every playback and publish, and can be exported as CSV to find the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Protect ingest too
&lt;/h2&gt;

&lt;p&gt;Protection is not only for viewers. Require a Stream Key on each ingest domain, and allowlist the publishers' public IP addresses where they are fixed, so nobody can publish into your channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What not to do
&lt;/h2&gt;

&lt;p&gt;Do not cap how many times or how long legitimate viewers may watch to fight abuse: that turns away the audience you are paying to reach. Use the layers above, which target the abusers, not the viewers.&lt;/p&gt;

&lt;h2&gt;
  
  
  On Streaming CDN
&lt;/h2&gt;

&lt;p&gt;Every customer sets these on its own playback and ingest domains in the console: signed URLs, Referer/Origin allowlists, request-source policies, address blocking, Stream Keys and publisher IP allowlists.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Streaming CDN delivers live video to browsers over WebRTC, with chat and votes on real-time data channels, adaptive quality, recording and playback protection. &lt;a href="https://streaming-cdn.ewin888.com/products/live-streaming" rel="noopener noreferrer"&gt;Learn more&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>livestreaming</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
