The Ultralight C API exposes the full API surface to C applications and provides the foundation for writing [Language Bindings](/docs/2.0/language-bindings) in other languages.

The C API follows a common set of conventions for memory ownership, callbacks, lifetimes, and threading, as documented below.

## Differences from C++

The C API mirrors most of the Ultralight C++ API, with a few key differences— each rule shown in the following table is covered in further detail below.

| C++ | C | What Changes |
| :--- | :--- | :--- |
| `RefPtr` and handle classes released at scope exit | `ulCreateX()` and `ulDestroyX()` | You destroy the handles you own |
| Copying a `dom::Element` or `js::Value` | `ulCreateXRef()` | Copying a pointer creates no new reference |
| Listener classes and lambdas with captures | Function pointer, `user_data`, and `destroy_user_data` | Pass captured state in `user_data` |
| `dom::Result` and `js::Result` | `false` or `NULL` with an error out-parameter | Errors pass through out-parameters instead of exceptions |
| `if (el)` and `IsEmpty()` | `ulXIsAlive()` and a `NULL` check | Check `NULL` separately from whether the page is gone |
| `==` on element, node, and layout handles | `ulDOMElementIsSame()` and siblings | Two lookups of the same element return different pointers |
| C++ exceptions in callbacks | Caught at the callback boundary | Nothing may unwind out of a callback |

## Including Headers

Two umbrella headers provide access to most of the library.

`<Ultralight/CAPI.h>` includes the core renderer, Views, sessions, configuration, platform handlers, and JavaScript headers. `<AppCore/CAPI.h>` covers AppCore features— including windows, panels, and layout— and automatically includes `<Ultralight/CAPI.h>`.

DOM and data-binding headers remain opt-in and aren't pulled in by either umbrella header. You must include them explicitly:

- `<Ultralight/CAPI/CAPI_DOMDocument.h>` and its `CAPI_DOM*` siblings for DOM manipulation
- `<Ultralight/CAPI/CAPI_DOMTriggers.h>` for persistent page listeners across navigations
- `<Ultralight/CAPI/CAPI_DOMData.h>` for declarative data bindings

The C API requires C17 or later. For compiler setup and linker configuration, see [Linking to the Library](/docs/2.0/linking-to-the-library).

## Passing Strings

Functions that accept a `ULString` take an explicit string handle that you create and destroy.

```c
#include <Ultralight/CAPI.h>

void LoadGreeting(ULView view) {
  ULString html = ulCreateString("<h1>Howdy!</h1>");
  ulViewLoadHTML(view, html);
  ulDestroyString(html);
}
```

Functions accept text in one of three forms, depending on their signature:

- `ULString` handles for core operations (such as `ulViewLoadHTML()`) and JavaScript evaluation.
- NUL-terminated UTF-8 `const char*` pointers for most JavaScript, DOM, and data-binding calls (such as selectors and property names).
- A UTF-8 `const char*` pointer with an explicit byte length for functions taking free text (such as `ulDOMElementSetTextContent()`).

A function that takes a pointer and a byte length reads exactly `length` bytes— the text doesn't need a terminating NUL and can contain embedded NUL characters. A length of 0 represents an empty string. To pass a standard NUL-terminated string to these functions, pass `strlen(text)` as the length.

## Managing Memory and Ownership

You destroy only the handles you create or explicitly own. A function returns an owned handle if its name starts with `Create` or its documentation states that you must call `ulDestroyX()` when finished. All other returned handles are borrowed— you must never call a destroy function on a borrowed handle.

### Owned and Borrowed Return Values

A `Get` name alone doesn't tell you whether a returned handle is owned or borrowed— ownership depends on the header family.

| API Family | What Getters Return | Example |
| :--- | :--- | :--- |
| Core (`ULRenderer`, `ULView`, `ULSession`, `ULConfig`) | Borrowed handles | `ulViewGetURL()` |
| JavaScript, DOM, and layout | Owned handles | `ulJSObjectGetProperty()`, `ulViewGetDOMDocument()`, `ulWindowGetLayout()`, `ulPanelGetView()` |
| Data bindings | Owned strings and binding handles, but borrowed `ULDOMDataTable` views and walk records (`ULDOMDataType` is never destroyed) | `ulDOMDataContextGetSchema()` (an owned string) |

Handles passed as callback arguments are always borrowed across every family. They stay valid only while the callback is running.

### Keeping a Borrowed Value

To keep a borrowed string past the next call, copy it with `ulCreateStringFromCopy()`.

```c
#include <Ultralight/CAPI.h>

static ULString saved_url = NULL;

void SaveURL(ULView view) {
  // Borrowed: the next ulViewGetURL() call overwrites it.
  ULString url = ulViewGetURL(view);

  if (saved_url)
    ulDestroyString(saved_url);
  saved_url = ulCreateStringFromCopy(url);
}
```

Core getters like `ulViewGetURL()` and `ulViewGetTitle()` return a borrowed string owned by the View. Each call overwrites an internal buffer, which invalidates any previous pointer returned by `ulStringGetData()`. Copying the string gives you an owned handle that remains valid until you destroy it.

To keep a borrowed handle in the JavaScript, DOM, or layout families, call the corresponding reference function (such as `ulCreateJSValueRef()`, `ulCreateDOMElementRef()`, or `ulCreatePanelRef()`). These functions return an owned reference that you must release with `ulDestroyX()` when finished.

### What Destroying a Handle Does

Destroying a handle typically releases only your reference without affecting the underlying object on the page or in the window.

| Handle | Effect of Calling Destroy |
| :--- | :--- |
| Last owning `ULJSAPI`, `ULDOMTriggers`, or `ULDOMDataContext` | Detaches it from every View |
| `ULDOMEventListener` | Releases your handle while the listener continues firing on the page (call `ulDOMEventListenerRemove()` to stop it) |

## Using Callbacks and User Data

Each callback setter manages a single slot on the target object. Setting a new callback replaces the existing one, and passing `NULL` removes it.

To share a single allocation across multiple callbacks, assign the destroy hook to only one setter.

```c
#include <Ultralight/CAPI.h>
#include <stdlib.h>

typedef struct {
  int title_changes;
  int url_changes;
} PageStats;

static void OnChangeTitle(void* user_data, ULView caller, ULString title) {
  (void)caller;
  (void)title;
  ((PageStats*)user_data)->title_changes++;
}

static void OnChangeURL(void* user_data, ULView caller, ULString url) {
  (void)caller;
  (void)url;
  ((PageStats*)user_data)->url_changes++;
}

void WatchPage(ULView view) {
  PageStats* stats = calloc(1, sizeof(PageStats));

  // The title slot owns stats and frees it; the URL slot only borrows it.
  ulViewSetChangeTitleCallback(view, OnChangeTitle, stats, free);
  ulViewSetChangeURLCallback(view, OnChangeURL, stats, NULL);
}
```

Ownership of `user_data` transfers directly to the callback slot. The corresponding `destroy_user_data` hook runs exactly once when the callback is replaced, cleared, or when the owning object is destroyed. If a registration call fails, the library still calls `destroy_user_data` immediately so cleanup never depends on whether registration succeeded.

A destroy hook never runs while its callback is actively executing. If a callback replaces itself, the previous `user_data` is freed after the callback returns.

Destroy hooks can be invoked on any thread, so write them to be thread-safe. Inside a destroy callback, do not call any library functions other than destroying handles.

> 🚧 Assign Destroy Hooks to a Single Slot
>
> Each callback setter owns its `user_data` pointer separately. If you register the same pointer with a destroy hook on multiple setters, the library frees it multiple times. Provide the destroy hook to one setter and pass `NULL` to the rest, or allocate separate state for each setter.

## Handle Lifetimes Across Navigations

JavaScript, DOM, and layout handles track identity rather than lifetime. Holding a handle never prevents a page, window, or layout node from being destroyed.

### Checking Handle State

A handle is in one of three states: valid, empty (`NULL` in C), or gone. A DOM or JavaScript handle enters the gone state when its page navigates away, its frame is removed, or its View is destroyed. A layout handle enters the gone state when its node is removed or its window closes. The concepts behind these states are covered in [DOM Handles and Errors](/docs/2.0/dom-handles-and-errors) and [Working with JavaScript Values](/docs/2.0/working-with-javascript-values).

Functions called on `NULL` or on a handle whose page is gone fail safely without crashing, and destroying these handles remains safe. When a page navigates away, its handles never become valid again, so you must obtain fresh handles for each new page (for example, in the DOM-ready callback).

Calling an `IsAlive` function such as `ulDOMElementIsAlive()` returns `false` for both `NULL` handles and handles whose page is gone. To distinguish between the two states, check whether the pointer is `NULL` first.

### Comparing Handle Identity

Do not compare element, node, or layout handles with `==`. Two lookups of the same element or layout node can return different pointer addresses. To test whether two handles refer to the same underlying item, call `ulDOMElementIsSame()`, `ulDOMNodeIsSame()`, or `ulLayoutNodeIsSame()`.

## Passing NULL Handles

Passing `NULL` to JavaScript, DOM, data-binding, or layout functions is harmless. The library treats `NULL` like a handle whose page is gone— calls return default values or fail quietly, and log warnings only at the Strict diagnostics level.

Core and AppCore functions do not accept `NULL`. Core functions log a fatal error if you pass `NULL` for a `ULRenderer`, `ULView`, `ULSession`, `ULConfig`, or `ULViewConfig`. AppCore functions crash if you pass `NULL` for a `ULApp` or `ULWindow`.

Core destroy functions are an exception— calls like `ulDestroyView()` accept `NULL` and do nothing.

## Handling Errors

A function that fails returns `false` or `NULL`, while errors raised during execution write to an out-parameter.

Here is how to check both failure paths when querying a DOM element:

```c
#include <Ultralight/CAPI.h>
#include <Ultralight/CAPI/CAPI_DOMDocument.h>
#include <Ultralight/CAPI/CAPI_DOMElement.h>

void ShowScore(ULView view) {
  ULDOMDocument document = ulViewGetDOMDocument(view);
  ULDOMError error = {0};
  ULDOMElement score = ulDOMDocumentQuerySelector(document, "#score", &error);

  if (error.code != kULDOMErrorCode_None) {
    // The page rejected the call (eg, a malformed selector).
    if (error.message)
      ulDestroyString(error.message);
  } else if (score) {
    ulDOMElementSetTextContent(score, "100", 3);
  } else {
    // No match, or the page is gone (ulDOMDocumentIsAlive() tells which).
  }

  ulDestroyDOMElement(score);
  ulDestroyDOMDocument(document);
}
```

### Error Out-Parameters

Functions in the JavaScript and DOM headers report execution errors through out-parameters. The JavaScript API uses `ULJSValue* exception`, which stores an owned value handle that you must destroy. The DOM API uses `ULDOMError* error`, which populates a `code` and an owned `message` string (which you must destroy if non-`NULL`). If you do not need the error details, pass `NULL` for the out-parameter.

The library writes to these out-parameters only when an active operation fails during execution. This includes page exceptions, malformed CSS selectors, and JavaScript errors raised by the library.

Passing a `NULL` handle, calling a function on a handle whose page is gone, or running a query that finds no matches leaves the out-parameter untouched. Call `ulXIsAlive()` to tell these cases apart from execution errors.

Zero-initialize `ULDOMError` before each call so you can check whether `error.code` changed from `kULDOMErrorCode_None`.

> 📘 Diagnostic Logging
>
> Operations on missing pages or empty handles fail quietly by default. Enabling developer mode with `ulConfigSetDeveloperMode()` causes JavaScript and DOM functions to log warnings whenever code calls a handle whose page is gone. You can set finer logging thresholds with `ulConfigSetJavaScriptDiagnostics()` and `ulConfigSetDOMDiagnostics()`. For details, see [Developer Mode and Diagnostics](/docs/2.0/developer-mode-and-diagnostics).

## Threading Rules

Every header family belongs to a specific owner thread. You must call its functions on that thread unless the documentation explicitly marks a function safe to call from any thread.

| Header Family | Owner Thread |
| :--- | :--- |
| Core (`ULRenderer`, `ULView`, `ULSession`) | The Renderer's thread (the thread that creates the Renderer and calls `ulUpdate()`) |
| JavaScript, DOM, and DOM triggers | The Renderer's thread |
| AppCore app, windows, and layout | The main thread (which is also the Renderer's thread under AppCore) |
| Data bindings | The context's home thread (the thread that created it) |

### Calling Functions from Any Thread

A subset of functions can be called from any thread:

- Destroy and `IsAlive` functions for JavaScript, DOM, data-binding, and layout handles (such as `ulDestroyJSValue()` and `ulDOMElementIsAlive()`).
- Most reference functions for those same families.
- Task posting functions, including `ulRendererPostTask()`, `ulAppPostTask()`, `ulJSContextPostTask()`, and `ulDOMDataContextPostTask()`.

These are the most common cross-thread functions, but not an exhaustive list. Each family guide and the API reference mark any others.

### Deferred Work and the Update Loop

Work originating off the Renderer's thread runs during `ulUpdate()`. This includes freeing handles destroyed from background threads, processing posted tasks, and running certain destroy hooks.

AppCore calls `ulUpdate()` automatically inside its run loop. If you run your own loop, make sure it continues calling `ulUpdate()` regularly— if your loop stops calling it, deferred cleanup and posted tasks wait indefinitely. For details on passing work to the Renderer's thread, see [Renderer in C](/docs/2.0/c-renderer).

## Writing Language Bindings

Bindings for languages such as Java, C#, and Rust build directly on top of the C API.

### Types and Calling Conventions

Your binding must match how C exposes functions, handles, and structs:

- **Functions use C linkage.** Every function uses the platform's default C calling convention (never `__stdcall`).
- **Handles are opaque pointers.** All library object handles pass across the API as opaque pointers.
- **Pass small value structs by value.** Match their C struct layout exactly for types such as `ULRect`, `ULColor`, and `ULRenderTarget`.
- **Initialize `struct_size` on descriptor structs.** When using AppCore descriptor structs, set `struct_size` to the byte size of your mirrored struct.

### Callbacks and Handlers

When passing callbacks to C, manage their state and errors carefully:

- **Store language closures in `user_data`.** Pass your language's closure handle (such as a `GCHandle` in C# or a boxed closure in Rust) in `user_data`. Free it inside `destroy_user_data`, which the library calls exactly once, potentially on another thread.
- **Platform handlers do not take `user_data`.** Keep their function pointers valid for the lifetime of the Renderer (in C#, prevent your delegate instances from being garbage collected). For details, see [Platform Handlers in C](/docs/2.0/c-platform-handlers).
- **Never unwind into the library.** Catch all exceptions, panics, and `longjmp` calls at the callback boundary. In a DOM callback, report any caught error with `ulDOMReportCallbackException()` so the library logs it and continues dispatching events.

### Finalizers and Object Identity

Clean up handles and check object identity using these methods:

- **Most handles are safe to destroy in finalizers.** Calling destroy and `IsAlive` functions for JavaScript, DOM, data-binding, and layout handles is safe from any thread.
- **Core handles must be destroyed on the Renderer's thread.** Dispose `ULView`, `ULRenderer`, and `ULSession` handles explicitly on that thread, or post the destroy call with `ulRendererPostTask()`.
- **Compare identity with `IsSame` functions.** If your binding caches wrapper objects by handle, compare them using the family's `IsSame` function rather than comparing raw pointer addresses.

### Strings and Editions

Account for text encoding, edition differences, and version checks in your code:

- **Strings use UTF-8 encoding.** Functions that accept a pointer and byte length work with non-NUL-terminated slices (such as Rust `&str`).
- **Use UTF-8 getters to avoid string allocations.** Functions like `ulJSValueGetUTF8()`, `ulDOMElementGetAttributeUTF8()`, and `ulDOMElementGetTextContentUTF8()` write into your provided buffer and return the required size when the buffer is too small.
- **Feature macros differ across editions.** The `UL_HAS()` feature macros remove declarations from the C headers at compile time, so one edition's library binary may lack functions declared in another. Resolve optional functions dynamically at load time, or generate separate bindings per edition.
- **Check library versions at runtime.** Call `ulVersionMajor()` and its companion functions to inspect the library version at runtime (the C API provides no compile-time version macros).

