# Custom fonts in a video API

Canonical: https://rendobar.com/blog/custom-fonts-video-api/
Author: Abdelrahman Essawy
Published: 2026-08-25
Updated: 2026-08-25

---

## Key takeaways

- 30 of 46 text-bearing compose jobs in production asked for a font that was not installed on the render image. Every one of them rendered, was billed, and came back in a typeface the caller never chose.
- Every one of those 30 jobs asked for the same string, "Arial, sans-serif". The old resolver read only the first entry, so the fallback the author actually wrote was discarded.
- A font stack is a failure policy, not just a preference list. Falling through to the next entry turns an unreachable font catalogue into a different typeface instead of a failed render.
- 16 scripts were rendered through the engine to check coverage. 13 worked with no configuration, including Arabic and Hebrew right-to-left joining, Devanagari conjuncts and Thai vowel stacking. Chinese, Japanese and Korean needed one font package.
- Colour emoji did not render through any of the three MLT text producers, at any size, with the font installed and correctly matched. That is an engine limit, not a configuration one.

## Short version

We went looking through production job records for how often callers asked for a font we did not have. The answer was **30 of 46 text-bearing compose jobs**, and every one of them succeeded. The render completed, the invoice went out, and the video came back set in a typeface nobody had chosen.

The requests were not exotic. Every one of those 30 asked for the same thing, the string `"Arial, sans-serif"`, which is a perfectly ordinary CSS font stack. Our resolver read the first entry, found no Arial on the render image, and handed that name to fontconfig, which substituted something. The `sans-serif` fallback the author had actually written was never consulted.

That is the whole problem with fonts in a rendering API. Nothing errors. You get a video, and it looks slightly wrong, and you may not notice for weeks.

_Every way to name a font, and what each one resolves to. Rendered by the compose API, job_a9836abdf0554063._

## Silent substitution is the expensive failure

A failed render is annoying and cheap. You see the error, you fix the input, you resubmit. A render that silently substitutes a font is expensive because the feedback loop is broken. The job status says `complete`. The output plays. The only signal is visual, and it only fires if somebody looks closely at the right frame.

Font resolution on Linux goes through fontconfig, whose job is to always return something. Ask for a family it does not have and it will find the closest match it can rather than refuse. That behaviour is correct for a desktop and wrong for a billed API call, because the caller has no way to tell the two outcomes apart.

The fix is not to make fontconfig stricter. It is to decide the substitution before it happens, and to say so when it does.

## A stack is a failure policy

CSS font stacks already encode exactly the right idea. `"Display, Playfair Display, Inter, sans-serif"` means try these in order and use the first one you can supply. The author has already told you what they want to happen when their first choice is unavailable.

Honouring that turns three separate failure modes into one predictable behaviour. A font file that returns a 404, a family the catalogue does not publish, a font service that is unreachable: all of them fall through to the next entry, and the render ships. The cost of any of those failures is the font, never the video.

It also means the resolution order is worth stating plainly. A file you supplied wins, then a family bundled into the render image, then a generic CSS keyword, then any family in the Google Fonts catalogue, fetched at render time. Declaring a font under a name that already exists shadows the built-in, which is the same thing `@font-face` does in a browser.

## Naming a font you uploaded

There is a smaller trap underneath all of this. A font file has a family name recorded inside it, and that name is frequently not the filename. Upload `BrandSans-Bold.otf` and the renderer may only answer to `Brand Sans`.

Shotstack documents this directly and warns about it, because you have to reference the embedded name and callers get it wrong. Their custom fonts guide also requires you to host the TTF yourself: there is no lookup by family name, and the format support is TTF only. Creatomate takes uploads in OTF, TTF and WOFF through its editor or a `fonts.add` modification. JSON2Video accepts a font URL inline in the `font-family` value.

We took a different position. You name the file yourself when you declare it, and we keep the mapping from your name to the one inside the file. You never open your font in an inspector.

One format note worth publishing, because it cost us a debugging session. **fontconfig will happily index a WOFF2 and report its family name, and the renderer then cannot rasterise from it and silently substitutes.** We verified this by matching a WOFF2 face by name and watching a completely different typeface come out. WOFF2 is what the web serves, so a developer copying a font URL out of their own site's CSS will hit this. We decompress it on ingest rather than handing it to the renderer.

## What actually renders

Font coverage claims are cheap, so we rendered 16 scripts through the engine and looked at the output.

Thirteen worked with no configuration at all: Latin, Cyrillic, Greek, Vietnamese with stacked diacritics, Turkish with its dotted and dotless i, Arabic with correct right-to-left joining, Hebrew, Thai with stacked vowels and tone marks, Devanagari with conjuncts, Bengali, Tamil, Amharic and Khmer. That is harfbuzz doing real shaping work, not glyph substitution.

_All sixteen, rendered by the compose API in one job (job_91e157f3c4a54cdf) after the CJK package was added. Arabic and Hebrew join right to left, Devanagari and Tamil form conjuncts, Thai stacks its vowels._

Chinese, Japanese and Korean rendered as empty boxes. That was one missing font package, now installed. It is worth saying that CJK failed **loudly**, as visible tofu boxes, which is the better failure mode. You see it immediately.

Colour emoji did not render at all. We tested five ways: default fallback, a fontconfig rule that made emoji codepoints resolve correctly, the font forced explicitly, the native bitmap strike size, and two alternative text producers. The glyphs are dropped by the text path in every case. That is a limit of the rendering engine rather than something a font feature fixes, and we would rather write it down than let you discover it in a render.

## Where this stops

This is one engine, MLT 7.41, and one text path. The multilingual results come from rendering those scripts and looking at the frames, not from a coverage table, so they hold for this image and this build and nothing else.

We read the published documentation and OpenAPI definitions for Shotstack, Creatomate and JSON2Video to describe what they offer. We did **not** test their renderers. We do not know whether their engines handle CJK, and we do not know whether they drop colour emoji the way ours does. Any claim in that direction would be a guess, so we are not making one.

The competitor capabilities described here were checked on the date this post carries. Font support is exactly the kind of thing that changes quietly, so treat the specifics as a snapshot rather than a standing fact, and check the linked sources before relying on them.

None of this covers the raw FFmpeg path, where the same missing font produces a failed render rather than a silent substitution. That case is measured separately in [how to burn subtitles into a video](/blog/ffmpeg-burn-subtitles/), where font access is one of three preconditions that broke all 8 raw attempts in our archive.
