Skip to content

Guides/Streaming from a drone

Streaming from a drone

A drone's video reaches a Velo room one of three ways: RTMP ingress, WHIP ingress, or a companion computer that joins as an ordinary participant. There is no drone SDK, and the first of the three needs no Velo code on the aircraft at all. What decides between them is how much latency you can accept and what you are willing to put on the airframe.

Three ways in

All three end in the same place: the aircraft is a participant in a room, and viewers join that room with a token like any other participant. They differ in what runs on the aircraft and how long the picture takes to arrive.

RTMP ingress
The controller pushes RTMP to a url and stream key Velo gives you. Nothing Velo-specific runs on the aircraft. Roughly two to five seconds of latency, which suits observation and survey and does not suit anything reactive.
WHIP ingress
The same endpoint with input_type whip. WebRTC ingest, so sub second rather than seconds. The aircraft or a companion computer has to speak WHIP, which a consumer controller will not.
A companion computer
A board on the airframe joins the room as a participant. Lowest latency of the three, and the only one that gets the room data channel, so telemetry rides the same connection as the video.

Start at the top of that list and move down only when the latency forces you to. Each step down adds hardware on the aircraft and code you have to maintain in the air.

RTMP ingress

Most consumer and enterprise controllers can push RTMP. Create an ingress against the room and Velo returns a publish url and a stream key. Paste those into the controller's live streaming settings and the aircraft appears in the room under the identity you named.

curl
curl -X POST https://api.usevelo.xyz/v1/rooms/survey-114/ingress \
  -H "Authorization: Bearer $VELO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input_type": "rtmp",
    "participant_identity": "aircraft-1",
    "participant_name": "Survey aircraft"
  }'
json201 Created
{
  "id": "b0f2c8d1-4a77-4b21-9c3e-2f1a5d6e8b04",
  "project_id": "prj_0a1b2c3d4e5f",
  "room": "survey-114",
  "namespaced_room": "prj_0a1b2c3d4e5f.survey-114",
  "ingress_id": "IN_7kQ2mXb9Rt4",
  "input_type": "rtmp",
  "participant_identity": "aircraft-1",
  "participant_name": "Survey aircraft",
  "url": "rtmp://rtc.usevelo.xyz:1935/live",
  "status": "inactive",
  "created_at": "2026-08-10T06:12:44Z",
  "updated_at": "2026-08-10T06:12:44Z",
  "stream_key": "sk_3b91d4f0c2a7e5d8"
}

Read url from the response rather than hard-coding it. It is issued by the media plane, and where the stream key appears inside it, Velo replaces the key with REDACTED before storing it. stream_key is returned once, on creation. Velo keeps only a hash, so reading the ingress back later will not give it to you again.

Viewers need nothing special. Mint a token for the room and connect from any client SDK. The aircraft is a participant with a video track, so a viewer subscribes to it the way they would subscribe to a camera.

curl
curl -X POST https://api.usevelo.xyz/v1/tokens \
  -H "Authorization: Bearer $VELO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "room": "survey-114",
    "identity": "observer-3",
    "role": "observer",
    "ttl_seconds": 3600
  }'

Ingress is a streaming feature, so a project on Free is refused with 402 feature_not_in_plan and field naming streaming. Listing, reading and deleting an ingress stay available on every plan.

Two to five seconds is the honest range. RTMP is a buffered TCP protocol and the encoder in the controller is tuned for platforms that expect a buffer. You cannot tune that away from the Velo side.

WHIP ingress

The same endpoint takes input_type: "whip". WHIP is WebRTC ingest, so the picture arrives in well under a second instead of several. The cost is that something on the aircraft has to speak it, and a stock controller will not.

curl
curl -X POST https://api.usevelo.xyz/v1/rooms/survey-114/ingress \
  -H "Authorization: Bearer $VELO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input_type": "whip",
    "participant_identity": "aircraft-1",
    "participant_name": "Survey aircraft"
  }'

The response has the same shape, and the same rule applies: take the endpoint from url rather than composing it. The media plane advertises WHIP on the same host as RTMP and a different port, so a published ingress looks like http://rtc.usevelo.xyz:8085/whip. Where the ingress service puts the stream key differs by version. Some builds return it inside url, which is why Velo replaces it with REDACTED before storing the URL, and others expect it as the bearer token the encoder sends. Use whichever the response hands you, and if url already carries the key, your encoder needs nothing else.

That WHIP endpoint is advertised over http, not https, so a stream key sent as a bearer token crosses the network in the clear. Until it is fronted by TLS, treat a WHIP stream key as readable by anything on the path, keep its lifetime short, and delete the ingress when the flight ends.

bashcompanion board
gst-launch-1.0 -e \
  v4l2src device=/dev/video0 \
  ! video/x-raw,width=1280,height=720,framerate=30/1 \
  ! v4l2h264enc extra-controls="controls,video_bitrate=1500000,h264_i_frame_period=30" \
  ! video/x-h264,profile=constrained-baseline \
  ! h264parse config-interval=-1 \
  ! whipclientsink \
      signaller::whip-endpoint="$VELO_WHIP_URL" \
      signaller::auth-token="$VELO_STREAM_KEY"

whipclientsink ships in gst-plugins-rs; older builds call it whipsink and take the same two signaller properties. v4l2h264enc is the hardware encoder. Use it, or the equivalent on your board, and do not fall back to x264enc: CPU encoding on a board in an airframe will thermally throttle, and it will do it in flight rather than on the bench.

enable_transcodingis left to the media plane's default per input type when you omit it, and for WHIP that default is off. That is what keeps the latency down, and it means the codec you send has to be one the room can forward as-is. H.264 is the safe choice.

A companion computer

The lowest latency path is a board on the airframe joining the room as a real participant, not through an ingress. It is also the only path that gets the room data channel, so GPS, altitude, battery and heading ride the same connection as the video and there is no second socket to keep alive.

Velo's Go and Python SDKs are control plane only. They create rooms, mint tokens, manage ingress, recordings and broadcasts, and they do not publish media. There is no velo.publishTrack. Do not go looking for one.

So this path is two pieces. Velo mints the token and owns the room, the identity and the role. LiveKit's client SDK for whatever your board runs opens the track, using the token and urlVelo returned. That works because Velo's media plane is LiveKit: the token Velo mints is the token that connection expects.

pythonserver
import os

from velo import VeloClient

client = VeloClient(os.environ["VELO_API_KEY"])

token = client.create_token(
    room="survey-114",
    identity="aircraft-1",
    role="aircraft",
    ttl_seconds=3600,
)

print(token.token, token.url)

Hand those two values to the aircraft over whatever channel you already trust, then publish with LiveKit's own SDK. Nothing below this line is Velo code.

pythoncompanion board
import asyncio

from livekit import rtc


async def fly(token: str, url: str, source: rtc.VideoSource) -> None:
    room = rtc.Room()
    await room.connect(url, token)

    track = rtc.LocalVideoTrack.create_video_track("belly-camera", source)
    await room.local_participant.publish_track(
        track,
        rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_CAMERA),
    )

    await asyncio.Event().wait()

A token is minted for one identity and expires, so the board needs a way to ask for a fresh one before a long flight outlasts ttl_seconds, which is clamped to 24 hours at the most.

The role a drone holds

A drone publishes and never subscribes. Say that once, in a role, and it holds for every token minted against it. The role is baked into the token when it is minted, so a companion computer that is misconfigured, or rebuilt by someone who did not read this page, cannot widen its own permissions.

curl
curl -X PUT https://api.usevelo.xyz/v1/templates/tpl_0123456789ab/roles/aircraft \
  -H "Authorization: Bearer $VELO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "publish": {
      "allowed": ["video"],
      "video": {
        "width": 1280,
        "height": 720,
        "bitrate": 1500,
        "framerate": 30,
        "codec": "h264"
      },
      "simulcast": { "layers": [] }
    },
    "subscribe": { "enabled": false },
    "permissions": { "send_data": true },
    "priority": 1
  }'
The fields that matter for an aircraft
FieldTypeRequiredDescription
publish.allowedstring[]No["video"] for a camera with no microphone. This is the field that decides whether the token can publish at all, and it becomes can_publish and the publish source list inside the token.
subscribe.enabledbooleanNoDefaults to true. Set it false and the token carries can_subscribe: false, so the aircraft stops pulling down every other participant's video. On a metered SIM that is the whole receive half of its data, gone.
publish.video.bitrateintegerNoIn kbps. Budget conservatively. One to two megabits at 720p30 beats 1080p that stalls, and the uplink is what decides, not Velo.
publish.simulcast.layersobject[]NoAn empty array turns simulcast off, which is what you want on a single metered uplink. Leave it unset and three default layers are filled in instead, which raises the ceiling below to their sum.
permissions.send_databooleanNoBecomes can_publish_data. Required for telemetry on the data channel, and the only permission an aircraft needs.

Velo sums the role's publish profile into a bitrate ceiling and carries it in the token as the velo.publish.max_kbps attribute. With the role above that is 1500, the video bitrate, because simulcast is off. Leave the default three layers in place and it becomes 1850, their sum, which is not what you meant.

Be clear about what the ceiling does. The media node measures the participant's total published bitrate against it and reports sustained overage as a participant.publish_limit_exceededwebhook. Whether overage also mutes the track is an operator setting that is off by default. The ceiling tells you the aircraft is trying to negotiate its way past the uplink; it does not stop it. Set the encoder's bitrate on the aircraft as well.

That last point matters more on this page than on any other. The rest of the publish profile is a set of hints that Velo's own client SDKs apply when they publish. Nothing on an RTMP or WHIP path reads them, and LiveKit's SDK on a companion board does not read them either. Only the grants inside the token and the measured ceiling are server side. Templates and roles covers the rest of the fields.

Telemetry on the data channel

A room carries a data channel alongside its media, and it is the right place for position, altitude, battery and heading. It arrives on the same connection as the video, which means it arrives with roughly the same delay, and there is no second socket to open, authenticate or reconnect.

pythoncompanion board
import json

frame = {
    "lat": 0.3476,
    "lon": 32.5825,
    "alt_m": 118.4,
    "heading_deg": 214,
    "battery_pct": 62,
    "t": 1786600364,
}

await room.local_participant.publish_data(
    json.dumps(frame).encode("utf-8"),
    reliable=False,
    topic="telemetry",
)

Send telemetry unreliably. A dropped position frame is replaced by the next one a moment later, and a reliable channel will hold the queue up retransmitting a frame nobody wants any more. Use a topic so viewers can tell telemetry from chat without parsing every packet.

typescriptbrowser
import { connectToRoom, RoomEvent } from "@usevelo/client";

const room = await connectToRoom({ token, url });

room.on(RoomEvent.DataReceived, (payload, participant, _kind, topic) => {
  if (topic !== "telemetry" || participant?.identity !== "aircraft-1") return;
  const frame = JSON.parse(new TextDecoder().decode(payload));
  updateInstruments(frame);
});

This path is only open to a companion computer. An RTMP or WHIP ingress publishes a track and nothing else, so if you take one of those paths, telemetry needs its own transport. Your server can also push into the data channel with POST /v1/rooms/{name}/send-data, which is how you would relay telemetry that reaches your backend by another route.

Recording and restreaming

The aircraft is a participant, so everything Velo does with participants applies to it. Record the flight by recording that one participant, which gives you the aircraft's track without the observers talking over it.

curl
curl -X POST https://api.usevelo.xyz/v1/rooms/survey-114/recordings \
  -H "Authorization: Bearer $VELO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type": "participant", "identity": "aircraft-1"}'

Pushing the feed out to RTMP destinations runs at the same time and is a separate egress, so recording and restreaming do not compete for the same one. Up to eight destinations, or name a destination configured on the template instead of listing urls inline.

curl
curl -X POST https://api.usevelo.xyz/v1/rooms/survey-114/broadcasts \
  -H "Authorization: Bearer $VELO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["rtmps://live.example.com/app/flight-114"], "layout": "speaker"}'

Both raise webhooks as they change state, so the ground station can show that a flight is recording without polling. Webhooks lists the recording, broadcast and ingress events. Both are also streaming features, refused on Free with 402 feature_not_in_plan.

What a flight costs in data

Drone SIMs are metered and a flight is long. DataUsageMonitor samples the room transport and reports what a connection is actually consuming, so the number you plan with is measured rather than guessed.

typescriptbrowser
import { DataUsageMonitor } from "@usevelo/client";

const monitor = new DataUsageMonitor(room, { intervalMs: 5000 });

monitor.on("update", (snapshot) => {
  showFlightData({
    received: snapshot.bytesReceived,
    bitrate: snapshot.recvBitrateBps,
    minutes: snapshot.durationMs / 60000,
  });
});

Each snapshot carries bytesSent, bytesReceived, totalBytes, sendBitrateBps, recvBitrateBps, estimatedCostUgx and durationMs. Sampling starts on construction, every 3000 ms by default.

It measures the connection it is attached to, which is the honest limit here. On a ground station in a browser it tells you what watching the flight cost. It cannot tell you what the aircraft's SIM spent on an RTMP or WHIP path, because nothing of Velo's is running there. For the uplink side, read the modem, or work back from the bitrate you set and the flight time.

What this does not do

These are the constraints of the approach, not of Velo, and none of them are worked around by a setting.

  • Never fly by this video. Jitter and uplink drops make it unsuitable for control. It is for observation. Control stays on the RC link, and the aircraft stays flyable with the video gone.
  • The uplink is the bottleneck, not Velo. A cellular link from a moving aircraft is the worst part of the path. Budget conservatively; one to two megabits at 720p30 beats 1080p that stalls.
  • Hardware encoding is required. A Pi 4 or a Jetson has it. CPU encoding will thermally throttle in flight, which is the worst time to find out.
  • There is no drone specific SDK. There is an ingress endpoint, a token endpoint and a role. The RTMP path needs no Velo specific code on the aircraft at all.
  • Flying beyond visual line of sight is regulated everywhere. A video feed does not satisfy a visual line of sight requirement. What you may fly, and how far, is a question for your aviation authority, not for this page.

Was this page useful?

Edit this page on GitHub