Appearance
Apps: host interface
What a module may import and how each import behaves: the five host functions, their requests, answers and limits, and what the host does around them. The TypeScript SDK wraps all of it (ctx.http, ctx.hmac, ctx.hmacVerify, ctx.documents.read, ctx.log, ctx.secret), so this page is for authors who write a module in another language through one of Extism's plug-in development kits (Rust and Go among them), and for anyone who wants to know what the SDK does underneath. The contract is version 1 (interface: 1 in the manifest); its schemas are packages/core/src/app.ts.
Imports and exports
A module imports the host functions from the namespace extism:host/user, the one every Extism PDK can import from. Besides them it may import Extism's own kernel (extism:host/env, which the PDKs use for memory and input and output) and WASI preview 1 for its clock and random numbers. Nothing else: no WASI socket (sock_*) or path (path_*) function, so no file system and no socket, and Extism's built-in HTTP is switched off, so a PDK's own HTTP client reaches nothing. The upload refuses a module that imports anything else.
Each host function takes the offset of a block in Extism's memory that holds a JSON request and returns the offset of a block that holds the JSON answer. A function that cannot do what was asked answers { "error": "…" }; the module decides what to do with it (the SDK throws an AppError that is not retried).
The exports take their envelope as the call's input and write their answer as its output (see Envelopes); each returns 0, the failures being in the answer. This is the interface file the SDK's build hands the JavaScript PDK:
ts
declare module 'main' {
export function manifest(): I32;
export function invoke(): I32;
export function handle_webhook(): I32;
export function handoff(): I32;
}
declare module 'extism:host' {
interface user {
http_request(ptr: I64): I64;
hmac(ptr: I64): I64;
hmac_verify(ptr: I64): I64;
document_read(ptr: I64): I64;
log(ptr: I64): I64;
}
}The module defines its own memory. The platform writes the manifest's memoryMiB (16 pages of 64 KiB per MiB) into the module as its memory's maximum when the module is uploaded, which is the cap the engine enforces; a module that imports its memory is refused, and the runner refuses one whose memory has no maximum.
Every function is bound to one call: its tenant, the install's configuration, secrets and approved hosts, the call's limits and, for an app that declares needs: ['documents'], the tenant's documents. Nothing a module passes can change the binding. During the manifest export every function refuses.
Secrets
A module never holds a secret's value. It names a secret of the install with the placeholder {{secret:NAME}} wherever a request carries it, and the host replaces the placeholder on the way out: in the URL, in header values, in a string body, in a { json } body, in the values of multipart parts and in the prefix and suffix of a signature. A base64 body and document bodies are sent as they are. A placeholder for a secret the install has no value for fails the request. hmac, hmac_verify and sign take a key by name instead: { "secret": "webhookSecret" }.
The host also keeps the values out of everything that leaves a call. It holds a redactor built from the call's secret values in their plain, URL-encoded and base64 forms, and passes through it the error answers of the host functions, every log line, the module's own answer (so a value a vendor echoed back never reaches an invocation record) and the message of a module that traps. A redacted value reads as its placeholder. Values shorter than four characters are not searched for.
http_request
One HTTP request, sent by the host while the module waits.
json
{
"method": "POST",
"url": "https://api.acme-screening.example/v1/screen",
"headers": { "authorization": "Bearer {{secret:apiKey}}" },
"body": { "json": { "name": "Evil Corp" } },
"timeoutMs": 10000,
"responseAs": "text"
}| Field | Default | Notes |
|---|---|---|
method | GET | GET, POST, PUT, PATCH, DELETE or HEAD. |
url | 4,096 characters at most, secret placeholders allowed. | |
headers | {} | Names are lower-cased; values may hold placeholders. |
body | none | A string, { json }, { base64 }, { document: id } or { multipart: [...] } (below). |
sign | none | An HMAC the host computes over the final body and puts in a header (below). |
timeoutMs | 10000 | 100 to 30,000 ms, within the call's own deadline. |
responseAs | text | text answers bodyText (UTF-8), base64 answers bodyBase64 (for binary bodies). |
The answer is { status, headers, bodyText } or { status, headers, bodyBase64 }, header names lower-cased and repeated headers joined by a comma and a space. Any status is an answer; only a request that could not be completed is an { error }.
The rules the host applies before it sends anything:
- The URL is https and carries no user name or password;
httpis accepted only on a runner that allows private networks (APP_RUNNER_ALLOW_PRIVATE_NETWORKS, for development stacks). - Its hostname, lower-cased, must be one of the hosts the install approved:
api.vendor.examplematches itself only,*.vendor.examplematches one or more labels before.vendor.examplebut notvendor.exampleitself. An IP address as the host is refused unless private networks are allowed. - The host resolves the name once, refuses a private, loopback or link-local address, and connects to the address it checked, so the name cannot change between the check and the connection. Redirects are not followed.
- A request that sets
host,content-length,transfer-encoding,connection,expectorupgradeis refused; the host setscontent-lengthitself,user-agenttoAletheia-Apps/1unless the request sets one, andcontent-typefrom the body when the request sets none. - At most 25 requests per call; a response body over 8 MiB fails the request.
Bodies:
| Body | Sent as |
|---|---|
"text" | the string, placeholders replaced |
{ "json": value } | the value as JSON, placeholders replaced, content-type: application/json |
{ "base64": "…" } | the decoded bytes |
{ "document": "<id>" } | one of the tenant's clean documents, with its content type, streamed by the host |
{ "multipart": [part, …] } | multipart/form-data with up to 20 parts: { name, value, contentType? } or { name, filename?, document } (the document's own file name and content type by default) |
A document body or part counts as a document read and needs the app to declare needs: ['documents']; its bytes never enter the module.
Signing a request: sign
Some vendors sign each request over its exact bytes; Sumsub's X-App-Access-Sig, for one, is an HMAC-SHA256 over the timestamp, the method, the path and the body. With sign, the host computes that signature itself, over the body it is about to send (a multipart or document body included), so the body never has to enter the module:
json
{
"sign": {
"header": "x-app-access-sig",
"algorithm": "sha256",
"key": { "secret": "secretKey" },
"encoding": "hex",
"prefix": "1760000000POST/resources/applicants?levelName=id-only"
}
}The signature is the HMAC (sha1, sha256 or sha512) under the key of prefix + the encoded body + suffix, both optional and with placeholders replaced, in hex (the default) or base64, set as header. The key is a secret of the install by name, or { "text": "…" }.
hmac
{ algorithm, key, data, encoding } answers { "signature": "…" }: the HMAC of data ({ "text": "…" } as UTF-8, or { "base64": "…" }) under key ({ "secret": "name" } or { "text": "…" }) with sha1, sha256 or sha512, in hex (the default) or base64.
hmac_verify
The same request with a signature to check answers { "valid": true | false }. The comparison takes constant time, a hex signature compares regardless of case, and a signature of another length is simply not valid. This is how a webhook's signature is checked under the install's webhook secret:
json
{
"algorithm": "sha256",
"key": { "secret": "webhookSecret" },
"data": { "text": "{\"id\":\"evt-1\",\"reference\":\"run-1:step-2\"}" },
"signature": "5d2f…"
}document_read
{ "id": "<document id>" } answers the document's metadata with its bytes in the module's memory:
json
{
"id": "0b1a2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"fileName": "passport.png",
"contentType": "image/png",
"sizeBytes": 48211,
"bytes": 1966080
}bytes is the offset of a memory block holding the document, which the module reads and frees (Memory.find(bytes) in the JavaScript PDK); it is the answer's last key, so a module without a JSON parser can still find it. Only the tenant's own documents with status clean are readable, and the bytes are the platform's processed copy (type checked, images re-encoded), never the raw upload. The host reads them through the API's document check with the call's token; a document that is not the tenant's, not clean or not found answers an error, and so does every read of an app without needs: ['documents'] or on a deployment without object storage. At most 10 reads per call, document bodies of http_request included.
log
{ level, message, fields? } with level one of debug, info, warn and error, a message of 2,000 characters at most and optional JSON fields, answers {}. The line goes to the runner's log tagged with tenantId, appName and callId, which override any field of the same name, so a line cannot claim another tenant. Message and fields are redacted. A call keeps 200 lines: the first line past the limit answers { "error": "log limit reached" }, later ones {}, and none is logged.
Limits
The host counts per call and refuses past these: 25 HTTP requests, 8 MiB per response, 10 document reads, 200 log lines, 1 MiB for the export's answer, and the deadline (the action's timeoutMs for invoke, 15 s for handle_webhook and handoff, 5 s for manifest). A call past its deadline is stopped by terminating the thread it runs in, whatever the module is doing. See Limits of a call.