btw: MoQ is under active development. The APIs and protocols are still evolving and will change. Most of this documentation is AI generated until things get more stable.

Skip to content

C Libraries

The C bindings expose Media over QUIC to C and C++ applications. Built on top of the Rust moq-net crate via FFI, with no Rust toolchain required at link time.

Libraries

libmoq

docs.rs

A C-callable shared and static library exposing the MoQ pub/sub API. Header files are generated by cbindgen and ship alongside the prebuilt binaries.

Features:

  • Static (libmoq.a) and dynamic (libmoq.so / libmoq.dylib / moq.dll) library targets
  • Auto-generated C header (moq.h)
  • No Rust runtime exposed to consumers
  • Works with any toolchain that can link a C library (CMake, Make, Meson, etc.)

Learn more

Installation

From prebuilt releases

Each libmoq-v* release ships a moq-<version>-<target>.tar.gz bundle with the static library, the dynamic library, and the generated header. Supported targets:

  • x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu
  • aarch64-apple-darwin

Download and extract a bundle (here 0.2.0 on Linux x86_64):

bash
ver=0.2.0
target=x86_64-unknown-linux-gnu
curl -fsSL "https://github.com/moq-dev/moq/releases/download/libmoq-v$ver/moq-$ver-$target.tar.gz" \
  | tar xz

That gives you a moq-$ver-$target/ directory containing include/moq.h and lib/ (libmoq.a plus the dynamic library).

Compile against it

libmoq.a is a static library, so your linker also needs the system libraries it depends on (media frameworks, the C++ runtime, and so on). The bundle ships a pkg-config file that lists them, which saves you tracking the set by hand:

bash
root=moq-$ver-$target
export PKG_CONFIG_PATH="$root/lib/pkgconfig"

cc subscribe.c $(pkg-config --cflags --libs --static moq) -o subscribe

Without pkg-config, pass the same flags yourself:

bash
pkg-config --libs --static moq   # prints exactly what to add

The static library (libmoq.a) links the whole Rust runtime in, so the result has no libmoq runtime dependency. To link the dynamic library instead, keep lib/ on your loader path (LD_LIBRARY_PATH on Linux, DYLD_LIBRARY_PATH on macOS) at runtime.

From source

If there's no prebuilt bundle for your target, build it yourself. You'll need a Rust toolchain:

bash
git clone https://github.com/moq-dev/moq
cd moq/rs/libmoq
cargo build --release

libmoq is part of the Cargo workspace, so the build emits to the workspace root target/ (two levels up from rs/libmoq/): ../../target/release/libmoq.a (static) and ../../target/release/libmoq.{so,dylib,dll} (dynamic), with the generated header at ../../target/release/moq.h.

Client configuration

moq_session_connect dials with the defaults. To pin a protocol version, adjust TLS trust, or tune the transport, build a client config and dial with moq_client_connect instead:

c
int client = moq_client_create();
if (client < 0)
    return client;

int result;

// Pin the handshake to one draft instead of offering every supported version.
struct moq_string version = { "moq-lite-05", 11 };
result = moq_client_set_versions(client, &version, 1);
if (result < 0) {
    moq_client_close(client);
    return result;
}

// Trust one self-signed relay without accepting every certificate.
struct moq_string fingerprint = { hex_sha256, strlen(hex_sha256) };
result = moq_client_set_tls_fingerprints(client, &fingerprint, 1);
if (result < 0) {
    moq_client_close(client);
    return result;
}

result = moq_client_set_quic_congestion_control(client, "delay", 5);
if (result < 0) {
    moq_client_close(client);
    return result;
}

int session = moq_client_connect(url, url_len, client, origin, 0, on_status, user_data);

// The config is copied into the session, so the handle can be released (or reused
// for another dial, or edited in between) right away.
moq_client_close(client);
if (session < 0)
    return session;

A client of 0 means the defaults, so moq_client_connect(url, len, 0, ...) is exactly moq_session_connect.

The setters fall into a few groups:

  • Protocol: moq_client_set_versions restricts what's offered during the handshake. Names are spelled the way the CLI spells them (moq-lite-05, moq-transport-19); moq_versions lists what this build offers so a menu can't drift from what the setter accepts. An empty list restores the default of offering everything.
  • TLS: moq_client_set_tls_fingerprints (pin a SHA-256 hex fingerprint, the native equivalent of the browser's serverCertificateHashes), moq_client_set_tls_roots, moq_client_set_tls_system_roots, moq_client_set_tls_host_name (SNI override), moq_client_set_tls_cert / moq_client_set_tls_key (mTLS), and moq_client_set_tls_disable_verify (development only: it accepts any certificate, so prefer a fingerprint).
  • Transport: moq_client_set_backend, moq_client_set_bind, moq_client_set_connect_timeout, moq_client_set_failover_delay / moq_client_set_resolution_delay (the two Happy Eyeballs delays: how long before the next address is also dialed, and how long the first one waits for the AAAA answer), and moq_client_set_websocket_enabled / moq_client_set_websocket_delay for the fallback that gets you through a UDP-blocked network. The QUIC backends are compile-time optional, so use moq_backends for the names this build actually accepts rather than assuming all three.
  • Tuning: moq_client_set_backoff_initial / _multiplier / _max / _timeout for reconnect pacing, and moq_client_set_quic_max_streams / _idle_timeout / _keep_alive / _gso / _mtu_discovery / _congestion_control / _qlog for the transport. The QUIC ones are ignored by the WebSocket fallback.

Every knob is its own setter, and a knob you never set stays at its default. That is deliberate rather than incidental: the C ABI is stable, so a new knob has to be a new function. Bundling several into one struct you pass by pointer would freeze their layout, and adding a field later would break every caller compiled against the old size. The optional strings (congestion_control, qlog, the TLS paths) are set-or-clear, where NULL or empty puts the knob back to automatic, so a setting can be undone without rebuilding the handle.

Setters for reportable knobs have a matching moq_client_get_*, and a knob you never set reads back as its default:

c
uint32_t client = moq_client_create();
uint64_t idle;
moq_client_get_quic_idle_timeout(client, &idle);   // 30000

So a fresh handle is the defaults, and a settings UI can read them instead of hardcoding numbers that go stale when a default is retuned. Same reason moq_versions exists for the version menu. The getters take an out-parameter and return zero or a negative code, since every value is a valid one and the return channel is for errors.

The knobs whose default depends on the backend (GSO, path MTU discovery, congestion control, the TLS root store) have no getter: there is no single value to report, and they stay on the backend's choice until you set them.

Two capabilities are compile-time optional, so ask before offering them:

  • moq_backends lists the QUIC backends this build has, the same way moq_versions lists the drafts. A name it doesn't report is rejected by moq_client_set_backend.
  • moq_qlog_supported reports whether traces can be captured. moq_client_set_quic_qlog stores a directory either way, but a non-empty directory is rejected while the client configuration is initialized, before a connection is created.

Combinations that would quietly drop a setting are rejected at dial rather than resolved by precedence. moq_client_set_tls_disable_verify accepts every certificate, so pairing it with a fingerprint or a root fails instead of ignoring the trust material, and the reconnect knobs must leave a non-zero delay or retrying would spin.

Setters validate what they can parse, so a typo'd version or an unparseable bind address fails at the setter with the reason in moq_error(), rather than being silently dropped at connect time. A value that parses but the transport can't express (an idle timeout past QUIC's millisecond varint, say) is caught when the connection is dialed instead. Either way moq_error() names it.

Callback lifetime

Any function that registers a callback (moq_session_connect, moq_origin_announced, moq_origin_consume_announced, moq_origin_request, moq_consume_catalog, moq_consume_video, moq_consume_audio, moq_consume_track, moq_consume_datagrams, moq_consume_video_raw, moq_consume_audio_raw, moq_consume_json_snapshot, moq_consume_json_stream) takes a void *user_data pointer that libmoq passes back to every callback invocation. The status code carries the lifecycle:

  • > 0: a live result you can use: a frame, catalog, or announce ID (or 1 to mean "session connected"). May fire any number of times.
  • 0: closed cleanly. Terminal.
  • < 0: closed with an error. Terminal.

A positive result that is itself a handle must be freed once you're done with it (e.g. a broadcast from moq_origin_request via moq_consume_close). moq_origin_announced is the notable repeat case: it delivers a fresh announce ID for every announce / unannounce event, so free each one with moq_origin_announced_free after reading it with moq_origin_announced_info, or they accumulate for the life of the listener.

Once a callback fires with any non-positive (<= 0) code, libmoq will never invoke it again and never touch user_data again. Release user_data in response to that final callback.

The matching *_close function only requests shutdown: it returns immediately, does not free user_data, and does not cancel the final callback. The terminal callback still fires (on libmoq's internal thread) once the background task stops, and that is the one safe point to free user_data. This means you never have to guess whether an in-flight callback is still running after close, and you don't need an external weak-reference or refcount around user_data.

Because the terminal callback runs on libmoq's thread, bindings that own thread-affine objects (e.g. a Qt QObject) should hop to the owning thread to perform the actual destruction; the user_data lifetime contract holds regardless of which thread tears the object down.

Threading

Every function may be called from any thread, and a handle is not tied to the thread that created it.

The raw publish functions (moq_publish_video_raw_frame, moq_publish_audio_raw_frame) block the caller until the codec has taken the frame, which is what paces a publisher against its encoder. Calls on the same producer are serialized, but concurrent calls have no defined order, so use one thread when frame, cut, or bitrate order matters. A second producer keeps encoding, and consume callbacks, frees, and shutdown are unaffected.

Error handling

Functions return a negative code on failure (0 or a positive handle on success). The code identifies the kind of failure, but the human-readable reason is available separately via moq_error():

c
int rc = moq_consume_catalog_close(catalog);
if (rc < 0) {
    fprintf(stderr, "close failed: %s\n", moq_error());
}

moq_error() returns the reason for the most recent failed call on the calling thread, including detail the numeric code can't carry (which URL failed to parse, why a decode failed, etc.). The returned pointer is valid until the next libmoq call on that thread, so copy it if you need to keep it. It is only meaningful after a call returned a negative code; check the code first. Errors delivered through status callbacks carry their code directly, so read moq_error() from inside the callback if you want the matching reason.

A server can reject the connection on auth grounds: unauthorized (HTTP 401) or forbidden (HTTP 403). Each returns its own distinct negative code (with moq_error() reporting "unauthorized" / "forbidden"). These are terminal, so distinguish them from a transient transport failure and stop rather than reconnecting.

Failed calls are reported only through the return code and moq_error(), not logged. To surface libmoq's internal logs (moq-net / QUIC activity), call moq_log_level("debug") (or "trace", "info", etc.) to install a tracing subscriber.

Shared video properties

moq_publish_video_properties replaces the catalog properties shared by every video rendition in one update. A false has_* flag clears that property, and rotation is normalized to the nearest clockwise quarter turn. Read the same snapshot with moq_consume_video_properties:

c
moq_video_properties properties = {
    .display_width = 1080,
    .display_height = 1920,
    .has_display = true,
    .rotation = 90.0,
    .has_rotation = true,
    .flip = false,
    .has_flip = true,
};

if (moq_publish_video_properties(broadcast, &properties) < 0) {
    fprintf(stderr, "video properties failed: %s\n", moq_error());
}

moq_video_properties snapshot = {0};
if (moq_consume_video_properties(catalog, &snapshot) < 0) {
    fprintf(stderr, "video properties failed: %s\n", moq_error());
}

Stalled video renditions

moq_consume_video_stalled reports whether the publisher recommends temporarily avoiding a rendition. The track remains directly usable, and catalogs that omit the hint report false. Pass the same catalog snapshot and rendition index used by moq_consume_video_config:

c
bool stalled;
if (moq_consume_video_stalled(catalog, index, &stalled) < 0) {
    fprintf(stderr, "video stalled state failed: %s\n", moq_error());
}

Raw media

The moq_publish_media_* and moq_consume_video / moq_consume_audio calls carry already-encoded frames, for a caller that brings its own codec. The _raw calls carry uncompressed media instead and run the codec inside libmoq, so a C application can publish pixels and PCM without linking one.

moq_publish_video_raw opens an encoder and a video track together. Resolution, framerate, and pixel layout are fixed there, so each moq_video_encoder_frame carries only pixels and a timestamp:

c
struct moq_video_encoder_input input = {
    .format = MOQ_VIDEO_PIXEL_FORMAT_RGBA,
    .width = 1280,
    .height = 720,
    .framerate = 30,
};
struct moq_video_encoder_output output = {
    .codec = MOQ_VIDEO_CODEC_H264,
    .kind = MOQ_VIDEO_ENCODER_KIND_AUTO,  // hardware if available, software otherwise
};

int32_t video = moq_publish_video_raw(broadcast, &input, &output);
if (video < 0) {
    fprintf(stderr, "publish video failed: %s\n", moq_error());
}

struct moq_video_encoder_frame frame = {
    .timestamp_us = pts_us,
    .data = rgba,
    .data_size = 1280 * 720 * 4,
};
moq_publish_video_raw_frame(video, &frame);

Every zero in the output config means "pick a default": bitrate derives one from the resolution and framerate, gop uses roughly two seconds. data is borrowed only for the call, and must be exactly one picture at the configured resolution: the frame carries no dimensions of its own, so a wrong-sized buffer is rejected rather than reinterpreted. (The decode side's moq_video_frame does carry dimensions, since there they are whatever the stream turned out to be.) A hardware encoder pipelines, so a call that puts nothing on the wire is normal rather than an error.

The track is named after the codec (.avc3 / .hev1), and its catalog rendition is published immediately, read out of the encoder itself (which is opened once up front for exactly that), so a subscriber discovers the track through the catalog rather than a name you chose, and can find it before the first frame exists.

Two knobs run the live encoder. moq_publish_video_raw_bitrate retunes it without forcing a keyframe, which is cheap enough to drive from a congestion controller; a negative return means this backend can't retune while running, so stop adapting rather than stop publishing. moq_publish_video_raw_cut starts a new group at the next frame, which is optional: the encoder keyframes every gop frames on its own, and each of those cuts a group, so a subscriber can always join without it. Reach for it only when you want to place the boundaries yourself, aligning groups with something the encoder can't see such as a scene change or a source switch. Call moq_publish_video_raw_finish to flush the codec and end the track.

moq_publish_audio_raw is the same shape for PCM in and Opus out, and moq_consume_video_raw / moq_consume_audio_raw are the decode-side mirrors, delivering I420 frames and PCM through the usual callback contract.

Raw Tracks

Raw tracks carry arbitrary byte payloads without catalog or codec parsing. Use moq_publish_track_frame / moq_publish_group_frame to provide presentation timestamps in microseconds. libmoq creates raw tracks with a microsecond timescale by default (used when moq_track_info.timescale_valid is false or no info is given), matching the C ABI's timestamp units.

Use moq_publish_track_group_at to create sparse or replayed groups at an explicit sequence. moq_publish_track_finish_at declares the exclusive end while still permitting lower groups. moq_publish_track_abort and moq_publish_group_abort terminate a producer with an application error. Call moq_publish_track_finish after filling the groups below a declared end.

Subscribers receive raw frame handles from moq_consume_track; read each one with moq_consume_track_frame. The returned moq_frame.timestamp_us carries the timestamp, and keyframe is always false because raw tracks do not parse codec metadata.

Raw Track Options

moq_publish_track accepts optional publisher-side track properties: ordered controls prioritization only. When true, groups are prioritized in sequence order. Groups may always arrive out-of-order (or not at all) over the network.

c
struct moq_track_info info = {0};
info.priority = 3;
info.ordered = true;
info.latency_max_ms = 1000;
info.latency_max_valid = true;
info.timescale = 1000000;
info.timescale_valid = true;

int track = moq_publish_track(
    broadcast,
    name,
    name_len,
    &info);

moq_consume_track accepts optional subscriber delivery preferences. moq_consume_track_update changes them while the callback task is running. Fields ending in _valid decide whether the matching optional value is present:

c
struct moq_subscription sub = {0};
sub.priority = 5;
sub.ordered = true;
sub.latency_max_ms = 25;
sub.group_start = 10;
sub.group_start_valid = true;

int consumer = moq_consume_track(
    broadcast,
    name,
    name_len,
    &sub,
    on_frame,
    user_data);

sub.group_end = 20;
sub.group_end_valid = true;
moq_consume_track_update(consumer, &sub);

Pass NULL for either options pointer to use the moq-net defaults.

JSON tracks

For JSON payloads, libmoq frames the values for you. You opt into one of two modes. Snapshot (lossy) carries one value updated over time; a subscriber only sees the latest, via moq_publish_json_snapshot / moq_consume_json_snapshot. Stream (lossless) is an ordered append-log where every record is preserved, via moq_publish_json_stream / moq_consume_json_stream. Values are UTF-8 JSON documents.

c
struct moq_json_snapshot_config config = { .delta_ratio = 8, .compression = true };
int32_t json = moq_publish_json_snapshot(broadcast, "status", strlen("status"), &config);
const char *value = "{\"state\":\"live\"}";
moq_publish_json_snapshot_update(json, value, strlen(value));

// Subscribe: on_value fires with a value ID for each update; read it, then release it.
int32_t task = moq_consume_json_snapshot(consume, "status", strlen("status"), &config, on_value, user_data);
// In on_value: struct moq_json_value v; moq_consume_json_value(id, &v); ... moq_consume_json_value_free(id);

compression must match on the producer and subscriber. The consumer callback follows the same lifetime contract as every other (see above): release user_data on the terminal <= 0 call.

Use cases

  • C/C++ applications integrating MoQ without a Rust toolchain
  • Bindings for other languages that aren't already covered by Python, Kotlin, Swift, or Go
  • Legacy systems and embedded targets where pulling in Rust at build time is impractical

Source and issues

Licensed under MIT or Apache-2.0