Skip to content

App catalogue ​

The platform catalogue: the apps a release ships to every tenant, how a tenant installs one, the http-api connector in full, and how the catalogue is built and published with a release. The model apps have a page of their own, AI models. Writing an app of your own is covered in Apps: authoring; the contract in Apps: reference.

The apps ​

The catalogue ships six apps. http-api (version 0.5.0, category Integration) is one generic connector for any vendor's HTTP API. A tenant installs it once per vendor, with the vendor's endpoint, auth, secrets and webhook, and each workflow step or rule that calls the install gives the path, query, headers and body of its request (below), the run subject's documents among them once the install turns documents on. A step can also wait for a vendor that answers later: the vendor's webhook to the install completes it. It speaks the request format of Hoppscotch, the open-source API client, so a request built and tried there pastes in. The app is free; the vendor bills for its own API.

The five model apps (version 0.1.0, category AI models) put a language model in a step or a rule: claude, chatgpt (named "ChatGPT (OpenAI API)"), gemini, jev and openai-compatible, any server that speaks OpenAI's Chat Completions. Each answers a prompt as text or as JSON of the step's schema (generate, which Jev does not have) and decides named yes-or-no, choice and score questions (decide), each answer with its value and, where the model gives them, its probability and confidence, in the same shape whichever vendor answers, so one install falls back on another app's. Their requests go through the app runner's host, which holds the install's keys and speaks each vendor's API (AI models). They are free; the vendor bills for its tokens.

Aletheia is Decision Intelligence (DI) Infrastructure, not a vendor catalogue: rather than bundling fixed vendor connectors, the platform provides generic multi-install connectors (http-api for any HTTP/REST vendor, and native AI model apps for Claude, ChatGPT, Gemini, Jev, and OpenAI-compatible endpoints). Data lookups—such as sanctions screening, identity checks, company registries, and fraud scores—are configured dynamically through http-api installs, or custom apps built with the SDK (Apps: authoring). Every example policy pack ships pre-wired with an AI model app (chatgpt) for probabilistic reasoning and text analysis, and each pack's documentation explains where to wire additional data vendor steps (Adding a vendor step).

Installing a platform app ​

A platform app is installed like a tenant's own once it is published, as often as the tenant needs: each install has a name of its own, its configuration, its secrets and its webhook URL. Connect › Apps lists the catalogue with each app's installs; an app's page adds an install (the console proposes the app's name for the first), and an install's page holds the version, the configuration form generated from its schema, the hosts the install calls (the version's and those its configured URLs add, approved there), its secrets and whether it is enabled (Admin console). Through the API, the sequence is the one in Apps: authoring without the upload: create the install disabled with POST /app-installs, its hosts listed in approveHosts, store each secret with POST /app-installs/:install/secrets/:secret, then enable it with PUT /app-installs/:install. GET /apps names the published version to install (latest.id); the first example below runs the sequence for http-api.

A release that carries a newer version of an installed app publishes it beside the old one, and withdraws the version it no longer carries: each install keeps running its version and is marked updateAvailable until an administrator moves it (the install's page, or the same PUT with the new versionId), unless the install upgrades automatically. An install with autoUpgrade: 'compatible' (the install's page, or POST and PUT /app-installs) is moved by the API itself, once the release's start-up has reconciled the catalogue and every few minutes after: to the highest published release within its version's compatible range (within 1.x from 1.0.0 on, within 0.1.x on 0.x), never to a prerelease. A move that adds nothing applies at once; one whose version calls hosts or has capabilities the install's does not (reading documents, handling webhooks or the hand-off, each counted only once the install's configuration uses it, as http-api's documents and webhook do) waits for an administrator's approval as an install change, even when the tenant requires none; one that needs a secret not stored yet, a config the new version refuses, or an action the tenant's published workflows or rules call that the new version lacks waits until that is fixed, as the install's upgrade and Home say. When the version an install runs is withdrawn or the operator blocks it, the API moves it back to the highest release left in that range, and an administrator can move it back to the version the last automatic move left, withdrawn or not, by turning autoUpgrade off in the same save (Apps: reference).

http-api ​

An install of http-api stands for one vendor, under a name the tenant chooses (screening, idv-eu). It holds the vendor's base URL, whose host is the only one the install calls; the method, query parameters and headers every request sends; the vendor's auth; the install's variables, whose secret ones are its secrets; whether its steps may send documents; and how the vendor's webhooks reach it. Its action request sends one request: a call_app step or an app rule gives the path after the base URL, the method, the query parameters, the headers and the body, and passes run data in as variables. Its action start sends one request that starts work at the vendor, and the step then waits until the vendor's webhook says the work is done (Waiting for the vendor). The module puts the install and the step together and hands the request to the host, which fills in the secrets, builds the auth over the request it sends, streams the documents the body names and reaches the install's host only. The module never holds a credential or a document's bytes, and a step never chooses where a request goes.

Its manifest:

  • hosts: [] and hostsFromConfig: ['/endpoint']: each install calls the host of its endpoint and no other, approved when the install is saved (Hosts).
  • secrets: [] and secretsFromConfig: '/variables': the install's secret variables are its secrets (Secret variables).
  • needs: ['documents'] and documentsFromConfig: '/documents': a call reads the tenant's documents only through an install whose documents is true, and a run's call only its subject's (Documents in a body).
  • handles.webhook, with webhookExternalIdFromConfig: '/webhook/externalId': the API reads a webhook's external id where the install's webhook.externalId says, and an install without a webhook receives none (The install's webhook).
  • Two actions, request and start: 30 seconds per call, idempotent: false and no retry of their own (Responses and retries), and mappable: ['/variables']: run data fills their variables only (Variables). start is asynchronous, with async.callbackTimeoutSeconds of 604,800, seven days.
  • No hand-off.

Both halves use Hoppscotch's REST request format, with its field names, its auth objects and its <<variable>> syntax: the install's configuration is a base request with an environment, a step's input a partial request.

Install configuration ​

FieldDefaultNotes
endpointrequiredThe vendor's base URL, a path allowed: https://api.vendor.example/v2. Its scheme in lower case, no query, fragment, credentials or variables; a trailing / is ignored. Its host, the hostname with the port when the URL names one that is not the default (https://api.vendor.example:8443/v2 makes api.vendor.example:8443), is the install's one host. In production it is https, without an IP address or a port browsers refuse (Hosts); a stack that allows private networks also takes http and an IP address, as the smoke's vendor stub needs.
methodGETThe method of a step that names none: GET, POST, PUT, PATCH, DELETE or HEAD.
params[]Query parameters sent with every request, at most 20 entries.
headers[]Headers sent with every request, at most 20 entries.
auth{ "authType": "none" }The vendor's auth, a Hoppscotch auth object (Auth).
variables[]At most 50 { key, value?, secret, stepPlaces?, description? }, used as <<key>>, at most 20 of them secret (Variables); a secret one's stepPlaces lists where a step may use it (Secrets in a step).
timeoutMs10000The deadline of each request, 1,000 to 25,000 ms. A digest auth sends up to three requests, each with this deadline.
idempotencyKeyHeadernoneThe vendor's idempotency header, such as Idempotency-Key: each call sends its idempotency key in it, and the call then counts as safe to repeat (Responses and retries).
acceptStatuses[]At most 20 statuses (100 to 599) answered as output besides 2xx, such as [404] for a lookup that may find nothing.
responseHeaders[]At most 20 header names returned besides content-type, location, retry-after, x-request-id and etag.
documentsnone (off)true lets steps send the run subject's documents to the endpoint's host in a multipart or binary body; the platform gives a call no access to documents otherwise (Documents in a body). It needs no approval, and turning it off applies at once even in a save that waits for one; each change is audited.
webhooknoneHow the vendor's webhooks complete the start steps that wait for them: where they carry the external id, the event id and the status, what a completed step keeps, and how their signature is checked (The install's webhook). Without it the install receives no webhooks, its webhookUrl is null and start is refused.

An entry of params or headers is Hoppscotch's { key, value, active, description }: a key of 1 to 200 characters, a value of at most 8,192 (empty by default) and active, true by default; an entry with active: false stays in the configuration and is not sent. A field the schema does not know is refused, except in an auth object of one of Hoppscotch's types, which ignores the fields the host has no use for (Auth).

The install's page generates its configuration form from this schema, and the API checks a saved configuration against it: a literal credential, a malformed URL or an unknown field fails the save with 400 validation_error at its pointer. What a JSON Schema cannot express (a variable declared twice, a credential or a webhook's key that names no secret variable, a jwt auth without the key its algorithm needs, a webhook that reads its event id or status where its scheme does not sign) saves, and every call and webhook then fails with a final error that names the field; the install's page warns about such a credential as it is typed (Admin console). A secret placeholder written out in the configuration ({{secret:NAME}}) fails every call too: the configuration names a secret as <<name>>, and the module writes the placeholder. The same text in a step's parameters, headers, body or variables is data, sent as written (Variables); a step's endpoint refuses {{ (Step input).

Auth ​

auth is a Hoppscotch auth object, chosen by authType. The host builds each type over the request as it is sent (Authenticating a request); the fields marked credential hold exactly one secret variable, <<name>>, never literal text.

authTypeFieldsThe host sends
noneno auth
basicusername, password (credential)authorization: Basic …
bearertoken (credential)authorization: Bearer …
api-keykey, value (credential), addTo (HEADERS by default, or QUERY_PARAMS)the header key: value, or the query parameter key=value
digestusername, password (credential)the request, and again with authorization: Digest … after the vendor's 401 challenge
aws-signatureaccessKey, secretKey (credential), region, serviceName, serviceToken (credential, a session token, optional), addToAWS Signature Version 4 over the request, in its headers or its query
hawkauthId, authKey (credential), algorithm (sha256 by default, or sha1), includePayloadHash, ext, app, dlgauthorization: Hawk …
jwtalgorithm (HS256 by default; HS, RS, PS or ES with 256, 384 or 512), secret (credential, for HS), privateKey (credential, a PEM key, for the others), isSecretBase64Encoded, payload, jwtHeaders, addTo, headerPrefix (Bearer and a space by default), paramName (token by default)a token signed for each request, as authorization: <headerPrefix><token> or the query parameter paramName
akamai-egaccessToken, clientToken, clientSecret (credential), host (optional, the endpoint's host), headersToSign (header names separated by commas, signed in that order), maxBodySize (131072 when empty)authorization: EG1-HMAC-SHA256 …, Akamai EdgeGrid over the request as sent
aletheia-hmacheader, algorithm (sha256 by default, sha1 or sha512), key (credential), keyEncoding (utf8 by default, base64 or hex), encoding (hex by default, or base64), prefix, suffixan HMAC of prefix + the body as sent + suffix under the key, in header (Signing a request)
  • Every type takes authActive, true by default; false sends no auth.
  • aletheia-hmac is Aletheia's own type, for a vendor that signs each request with an HMAC of its own design over a timestamp, the method, the path and the body, such as Sumsub. Its prefix and suffix may use <<$method>>, <<$target>> (the path and the query as sent) and <<$host>>, which the host fills as the request goes out (example).
  • A jwt's payload and jwtHeaders are JSON text, as Hoppscotch keeps them ({} by default). The module fills their variables, as values in JSON, then parses them; iat, nbf and exp given as text ("now", "5m", "-30s") count from the time the host signs the token.
  • akamai-eg keeps Hoppscotch's fields as text, maxBodySize included, and leaves an empty one out. Its host may use non-secret variables and must be the endpoint's host, since the signature would cover a host the request never reaches; headersToSign cannot name authorization or a header the host sets; maxBodySize, at most 16 MiB, must be the API client's max_body at Akamai. Hoppscotch's own EdgeGrid does not sign as Akamai's clients do and the host does (Authenticating a request), so whether a request worked in Hoppscotch says nothing of whether it works here.
  • Hoppscotch's fields the host has no use for are accepted and ignored: a digest's realm, nonce, algorithm, qop, nc, cnonce, opaque and disableRetry (the host answers the vendor's challenge itself), a hawk's user, nonce and timestamp and an akamai-eg's nonce and timestamp (the host makes a fresh nonce and reads its clock).
  • Not accepted: OAuth 2.0, whatever the grant, and inherit, since an install has nothing to inherit from.

A credential is never written in the configuration, which analysts can read: the API refuses a credential field that is not one variable when the install is saved, and the module refuses one whose variable is not a secret variable of the install when it is called. The other fields (username, accessKey, authId, accessToken…) may be literal, use non-secret variables or use secret ones.

Variables ​

Text in a request names a variable as <<name>>, the way Hoppscotch does. A call knows four kinds:

KindDeclaredValueUsed in
The install's, plainin variables, { "key": "region", "value": "eu" }its valueany text of the install or the step
The install's, secretin variables, { "key": "apiKey", "secret": true }, without a valuethe secret of that name stored on the install, which only the host fills inthe values of the install's params and headers, and its auth; in a step, where its stepPlaces allows (Secrets in a step)
The step'sin the step's variables, usually filled from run data (below)any JSON valueany text of the install or the step
Built-innone<<$timestamp>> (Unix seconds), <<$isoTimestamp>>, <<$randomUUID>>, <<$idempotencyKey>> and <<$callbackUrl>>; one value each per callany text of the install or the step; <<$method>>, <<$target>> and <<$host>> only in an aletheia-hmac prefix or suffix
  • Secret variables are the install's secrets: their names become its secret slots, their values are stored sealed on the install (Connect › Apps, or POST /app-installs/:install/secrets/:key) and never kept in the configuration, and each one the configuration uses, or a step may use, is required before the install can be enabled. The module turns <<apiKey>> into the call's placeholder, {{secret:apiKey:TAG}} with the tag the host drew for the call, which the host fills in on the way out (Secrets). One used anywhere else (a key of the install's params or headers, a step's input outside the places its stepPlaces lists, a jwt's payload or jwtHeaders, an aletheia-hmac's prefix or suffix, or a name such as an api-key's key) fails the call; the install's endpoint takes no variable at all.
  • A step's variable may stand for a plain variable of the install of the same name, never for a secret one, which fails the call.
  • Built-in values are made once per call, so a timestamp in a header and in a signature's prefix is the same text, and an attempt that a step's retry sends again gets new ones. <<$idempotencyKey>> is the call's idempotency key, the same on every attempt of a step in a round and per rule for a rule's call, a UUID; a test call has none, and gets its own id. <<$callbackUrl>> is the install's webhook URL, for a vendor that takes a callback address with each request (Waiting for the vendor).
  • An unknown name fails the call with a final error that names it. So does a name that Hoppscotch allows and a variable here cannot have (with - or ., such as <<api-key>>: rename the variable to letters, digits and _), and a built-in of Hoppscotch's other than the ones above (<<$guid>>, <<$randomInt>>…).
  • One pass. A value put in is never read again for variables, so run data, which enters through the step's variables only (below), stays text when it holds <<apiKey>>. Run data that reads like a placeholder, such as an applicant named {{secret:apiKey}}, is text as well: the host fills only the placeholders that carry the tag it drew for the call, which the module alone writes, so the vendor receives the name as written.

Run data enters through the step's variables only, filled by inputMapping: { "variables.customerId": "subject.customerId" } sets <<customerId>> from the run context. request declares mappable: ['/variables'], so a mapping of any other field (the endpoint, params, headers or body) is an error when the workflow or rule is validated and fails every call before the module runs (Input mapping): run data never writes the text that names variables, secret ones included. A mapped path that does not resolve leaves its variable out, and a request that uses it then fails; a literal value in the step's input.variables, which the mapping overrides when the path resolves, gives it a fallback.

A value is encoded for the place it lands:

PlaceThe value
A segment of the step's endpointpercent-encoded as one segment, so a/b becomes a%2Fb; an object, an array or null fails the call, and so does a segment that a variable leaves empty or that is . or .., or holds one between slashes, once decoded (a/../b, or a literal ..%2Fadmin, since some servers read %2F as /). A secret is percent-encoded by the host as it fills it in, and stays one segment
A query parameter's key or valuepercent-encoded; a secret, in a value only, is encoded by the host once it is filled in
A header valueas text; a line break, another control character or a character beyond Latin-1 fails the call; a secret goes in as it is
A JSON body, inside a stringthe value as escaped text: "name": "<<name>>" stays valid JSON whatever the name holds; a secret is escaped by the host as it fills it in
A JSON body, barethe value as JSON: "amount": <<amount>> inserts a number, a boolean, an object or an array, and a string as a quoted JSON string; a secret fails the call
A text bodyas text, an object or an array as JSON; a secret as it is

An install's plain variable is text, so used bare in a JSON body it inserts a quoted string: for the value 100, "amount": <<amount>> sends "amount": "100", where Hoppscotch would send the text as it is. A number, a boolean or an object comes from the step's variables, which keep the type the run data has.

Secrets in a step ​

A secret variable stands in the install's own query parameters, headers and auth. For a vendor that wants its key elsewhere, such as in the path (/v3/<key>/lookup) or in a JSON body, the variable's stepPlaces lists where a step may use it:

json
{ "key": "apiKey", "secret": true, "stepPlaces": ["path", "body"] }
stepPlacesA step may use the secret inThe host fills it in
patha segment of its endpointpercent-encoded, so a value holding / stays one segment; a value that is . or .. is refused, since the request would leave the path
querythe value of one of its paramspercent-encoded
headersthe value of one of its headersas it is
bodyits body: inside a JSON string, a text body, the value of a form field or of a multipart text partescaped for the JSON string it stands in; as it is in a text body and a multipart part; form-encoded in a form
  • None by default. A step that uses a secret where its stepPlaces does not list the place fails the call with a final error naming the field and the place: input: headers.0.value: apiKey is a secret variable of the install, and its stepPlaces do not list headers.
  • Never in a name. A parameter's or a header's key and a form field's name never hold a secret, whatever stepPlaces lists, and a step's variables never give one.
  • JSON. A secret stands only inside a JSON string ("key": "<<apiKey>>"); bare ("key": <<apiKey>>) it fails the call. The JSON body goes out as the text the step wrote, byte for byte, so a number keeps every digit ({ jsonText }).
  • Text bodies (XML, HTML, plain text) get the value as it is: a < or a & in it reaches the vendor unescaped.
  • Secret variables only. stepPlaces on a variable that is not secret fails the save with 400 validation_error, and each place is listed once.
  • The administrator's grant. Whoever edits a workflow or a rule, which needs no apps:write, chooses where among the listed places the secret goes, and run data never does: it fills the step's variables only, whose values name no variable; the install's administrator decides the places. A secret a step may use is required before the install can be enabled, whether or not the configuration uses it. Changing stepPlaces adds no host, so the save applies at once, and the save's audit event records each secret's places as secretPlaces (Audit log).
  • Failures name the path with the secret written as its variable, HTTP 400 GET /v3/<<apiKey>>/lookup: …, never its value.

Step input ​

The input of request, which a call_app step or an app rule gives as its input, run data filling its variables through inputMapping. Every field is optional:

FieldDefaultNotes
endpoint''The path after the install's endpoint, such as /customers/<<customerId>>: empty or starting with /, without //, \, ?, #, {{, white space or a . or .. segment (%2e forms included), so never a URL of its own. Variables may stand in its segments, a secret one where its stepPlaces lists path.
methodthe install'sGET, POST, PUT, PATCH, DELETE or HEAD.
params[]Query parameters, the install's kind of entries, at most 20.
headers[]Headers, at most 20.
bodynone{ contentType, … }: text of at most 1 MiB, a multipart form or one document, in one of the content types below. Refused with GET and HEAD.
auth{ "authType": "inherit" }inherit, the install's auth, or none.
variables{}At most 50 values by name (a letter, then letters, digits or _, 64 characters at most), any JSON.
acceptStatusesthe install'sThe statuses answered as output besides 2xx, in place of the install's.
pickthe whole bodyAt most 50 dot paths into the response body to keep, such as data.id (numeric segments index arrays).
responseAsautoHow the response body is read (Responses and retries).

The body's content type decides how it is read and sent:

contentTypebodySent as
application/json, application/ld+json, application/hal+json, application/vnd.api+jsonJSON text with variablesthe text with its variables filled in and its comments dropped, as Hoppscotch drops them, which must then parse as JSON (an empty body does not); sent byte for byte, so a number keeps every digit
application/xml, text/xml, text/html, text/plaintext with variablesthe text with its variables filled in
application/x-www-form-urlencodedHoppscotch's key-value text: one key: value per line, a line starting with # kept without being sentform fields, variables filled in their keys and values, as Hoppscotch fills them, and the whole encoded by the host; a key given twice, or one a variable leaves empty, fails the call
multipart/form-dataat most 20 entries, Hoppscotch's: { key, value, isFile: false, contentType?, active } for a text part, { key, isFile: true, document: "<<documentId>>", filename?, active } for a documenta multipart form, the host setting its boundary: each text part with its variables filled, each document as its bytes, file name and type (Documents in a body)
application/octet-streamdocument: "<<documentId>>" in place of bodythe document's bytes (Documents in a body)

A text body's content type is sent as content-type, in place of a content-type among the install's headers; one among the step's own headers wins over both, so a vendor that wants application/x-amz-json-1.1 gets JSON written as application/json with that header. A multipart body's content type is the host's, with its boundary, and a document's is its own unless the step's headers name one (Documents in a body). In the key-value text the key ends at the first : and both sides are trimmed; Hoppscotch writes a key or a value that holds : or # as a JSON string in double quotes.

start takes the same fields but pick and responseAs, since its output is the webhook's, and two of its own:

FieldDefaultNotes
externalIdrequiredWhere the vendor's id for the work it starts comes from: { "from": "body", "path": "data.id" }, a dot path into the answer's JSON body; { "from": "header", "name": "X-Session-Id" }, a header of the answer; or { "from": "value", "value": "<<applicantId>>" }, text with variables, never a secret, filled before the request is sent. The id is text of 1 to 200 characters or an integer, written in decimal.
callbackTimeoutSeconds604800 (seven days)How long the run waits for the webhook: 60 to 604,800 seconds.

How the install and the step combine ​

  • URL. The install's endpoint, its trailing / dropped, then the step's endpoint: https://api.vendor.example/v2 and /customers/<<customerId>> give https://api.vendor.example/v2/customers/c-81. The host always comes from the install.
  • Query. The install's active params, then the step's; a step's key replaces every entry of the install with that key. Keys and values are percent-encoded. The parameter an api-key or a jwt auth adds to the query is refused from either side.
  • Headers. The install's active headers, then the step's; names compare without regard to case, and the step's entry wins; a name listed twice on one side is sent once, its values joined by a comma and a space. Names that the auth, the idempotency key or the host set are refused from either side, even when the step sends no auth: authorization, proxy-authorization, the header an api-key auth sets, x-amz-date, x-amz-security-token and x-amz-content-sha256, an aletheia-hmac's header, the idempotencyKeyHeader, and host, content-length, transfer-encoding, connection, expect and upgrade.
  • Body. The step's, never on GET or HEAD; the install has none. Its content type replaces the install's content-type, and a step's own header wins over both, but for a multipart body, whose content type the host sets with the boundary: a step's fails the call.
  • Auth. The install's, unless the step's auth is none. The host builds it over the final request for each request, so another attempt carries a fresh signature, timestamp or token.
  • Idempotency. With idempotencyKeyHeader, each call sends its idempotency key in that header: the same on every attempt of one step in one run, and per rule for a rule's call (Retries, idempotency, circuits and records).
  • Sending. One request (up to three with digest: a stale nonce is answered once more), with the install's timeoutMs. The module logs one debug line per call: the method, the path as written (its variables unfilled) and the status, never a value.

A configuration or input the module cannot use (an unknown variable, a JSON body that does not parse, a refused header) fails the call before anything is sent, with a final error that names the field: config: <field>: … or input: <field>: …, such as input: endpoint: unknown variable customerId, or input: body.body: line 3: … for a line of a form body.

Responses and retries ​

The vendor answersThe call
2xx, or a status in acceptStatuses (the step's, else the install's)answers the output below
408, 425, 429 or 5xxfails, retryable when the method is GET, HEAD, PUT or DELETE or the idempotency header was sent, final otherwise
any other status, an unaccepted 3xx included (redirects are not followed)fails for good: HTTP <status> <METHOD> <path>: <body>, the path without its query (a secret in it written as its variable, <<apiKey>>) and the body's first 200 characters
nothing: the request failed in transitfails, retryable when the connection was never made (network: not_sent), otherwise by the same method rule (http_request)
nothing: the host refused the request (a limit, a response over 8 MiB)fails for good

A failure names the request by its method and its path without the query: a request that failed in transit reads <METHOD> <path>: not sent: <reason> or <METHOD> <path>: failed in transit after it may have reached the vendor: <reason>, and one the host refused <METHOD> <path>: <reason>.

The output:

json
{
  "status": 200,
  "ok": true,
  "headers": { "content-type": "application/json", "x-request-id": "req_8d1c" },
  "body": { "hit": true, "matches": [{ "name": "Evil Corp", "score": 0.97 }] }
}
  • ok is true for 2xx; an accepted 404 answers ok: false.
  • headers are lower-cased: content-type, location, retry-after, x-request-id and etag, with the install's responseHeaders. set-cookie is never returned.
  • body by responseAs: auto reads JSON when the content type is JSON (application/json or a +json type) and the body parses, text otherwise; json reads JSON whatever the content type says, and fails the call when the body does not parse; text keeps the text; base64 answers bodyBase64 instead, for binary content. An empty body gives neither.
  • pick keeps only the paths it names, where they are: ["data.id", "data.review.status"] answers { "data": { "id": …, "review": { "status": … } } }. A path the body does not have is left out, and an array keeps its positions. pick needs a JSON body: on text, or with responseAs text or base64, it fails the call.
  • Output over 900 KiB as JSON fails the call for good, with a suggestion to pick: HTTP <status> <METHOD> <path>: the answer is <N> KiB, over the 900 KiB a step keeps: … (an export's answer is 1 MiB at most).

start answers the same way, except that an answer it accepts gives the external id of the work it began rather than an output (Waiting for the vendor).

request and start declare no retry and are not idempotent, so retries come from the caller:

  • A call_app step retries a retryable failure by its own retry (3 attempts, 2 s apart and doubling, by default), with the same idempotency key. A call that timed out at the runner, or that the runner failed, may have sent its request and ends the step at once.
  • An app rule calls once: any failure makes the rule error. A rule cannot wait for a webhook, so it calls request only: start is refused when the rule is validated.
  • Only retryable failures and timeouts count against the install's circuit, as the vendor's outages; a step that gets 404s cannot open it for everyone else.

A POST or PATCH that meets a 503 or a reset connection is therefore not sent again unless the install names the vendor's idempotency header, because the vendor may have acted on it.

Documents in a body ​

A step sends the run subject's documents, such as the identity document an applicant uploaded, in a multipart form or as a binary body, once the install's documents is true. The host streams each document's bytes to the vendor: they never enter the module.

  • Named by run data. A document is one variable, <<passport>>, whose value is the document's id, filled from the run through inputMapping: "variables.passport": "submission.identityDocument.fileId", the id a collection flow's file field stores (Documents). A value that is not a document's id (a GUID) fails the call, and a secret variable never names a document.
  • The run subject's only. A run's step or an app rule reads only the documents of its run's subject: another subject's document fails the call as one that does not exist, so run data that names another applicant's document sends nothing. A test call from the install's page sends documents only for a caller who may read every document of the tenant (documents:read).
  • The switch. While documents is absent or false, a body that names a document fails before anything is sent, input: body: this install sends no documents: turn documents on in its configuration, and the platform gives the install's calls no access to documents. Turning it on adds no host, since the documents go only to the install's one host, and needs no approval; turning it off applies at once, even in a save that waits for an approval of a host it adds. The save that turns it on or off is audited as app.install.documents.enabled or app.install.documents.disabled (Audit log).
  • Multipart. Hoppscotch's form entries. A text part's value takes variables, a secret one where its stepPlaces lists body, and its contentType is a media type written as it is, without variables or line breaks. A document part sends the document's bytes and type under its own file name, or filename, which may use variables. An entry with active: false is not sent, and a body without an active entry fails the call. The host reads at most 10 documents in one call.
  • Content type. The host sets a multipart body's, multipart/form-data; boundary=…: the install's content-type header is dropped, and a step's fails the call (input: headers.0.key: a multipart body’s content-type is the host’s, with its boundary). A binary body goes as the document's own type unless the step's headers name one; the install's is dropped.
  • Signed as sent. An aletheia-hmac auth signs the bytes the host sends, the documents included.

A step that uploads the applicant's passport to an identity vendor, with a JSON part describing it:

json
{
  "id": "passport",
  "type": "call_app",
  "app": "idv",
  "action": "request",
  "input": {
    "endpoint": "/resources/applicants/<<applicantId>>/info/idDoc",
    "method": "POST",
    "body": {
      "contentType": "multipart/form-data",
      "body": [
        {
          "key": "metadata",
          "value": "{\"idDocType\": \"PASSPORT\", \"country\": \"<<country>>\"}",
          "isFile": false,
          "contentType": "application/json"
        },
        { "key": "content", "isFile": true, "document": "<<passport>>" }
      ]
    }
  },
  "inputMapping": {
    "variables.applicantId": "applicant.body.id",
    "variables.country": "submission.country",
    "variables.passport": "submission.identityDocument.fileId"
  },
  "outputKey": "passportUpload",
  "next": "review"
}

A binary body names its one document in place of a text: { "contentType": "application/octet-stream", "document": "<<passport>>" }.

Waiting for the vendor: start and the webhook ​

A vendor that answers later, such as one that reviews an applicant's identity, takes a request that starts the work and posts the outcome to a webhook. start sends that request as request does, reads the vendor's id for the work from its answer (externalId) and answers pending: the run waits in waiting_callback until the install's webhook names that id, callbackTimeoutSeconds at most, and fails when none comes in time (Asynchronous actions and webhooks). The step's output is then { "body": … }, the webhook's body narrowed to the webhook's pick.

  • The external id names the session among the install's: the platform keeps one session per install and id, so a step whose id another session of the install holds fails at once rather than wait for a webhook the other one would take. Read the vendor's own id for the session from its answer, or give a value unique to the call such as <<$idempotencyKey>>; never the subject's id, which a re-run or a later round of the run would give again.
  • An answer without the id fails the step for good, naming the request and where the id was read: HTTP 201 POST /v2/sessions: no external id at body data.id: expected text of 1 to 200 characters or an integer. The vendor may have started the work, so the request is not sent again.
  • No webhook, no start. Through an install without a webhook, start fails before anything is sent: nothing would complete the step.
  • A callback address per request. A vendor that takes one with each request gets <<$callbackUrl>>, the install's webhook URL, in the body or a header.
  • Steps only. An app rule needs its answer at once, so it calls request; a rule that calls start is refused when it is validated.

The install's webhook ​

The install's webhook says where the vendor's request carries what the platform needs, and how its signature is checked. A source is { "json": "/data/id" } (a JSON Pointer into the body), { "header": "X-Session-Id" } or { "query": "session" }.

FieldNotes
externalIdRequired: the source of the external id start read: under hmac, hmac-timestamped and standard-webhooks, the body or a header the scheme signs (Standard Webhooks signs only its id and timestamp headers, so in practice the body); under token, any source (What a webhook does). The API reads it there, from the configuration as saved, to find the session before any module runs.
eventIdThe source of the vendor's event id, for de-duplication: the body, or a header the scheme signs. Without it, or when the request holds none there, the event id is the HMAC-SHA256 of the external id, a NUL byte and the body.
status{ at, completed, failed, pending }: the source of the vendor's status (the body, or a header the scheme signs) and the values that complete the step, fail it or keep it waiting, at most 20 each and each value once; a value in none of them is ignored. Without status, every verified webhook completes its session.
errorThe source of the vendor's reason, kept with the session, 2,000 characters at most; a failed session without one names the vendor's status.
pickAt most 50 dot paths into the body a completed step keeps, as a step's pick reads them; the whole body without it.
signatureRequired: the scheme and its fields, below.

Status values compare as JSON values: "3" and 3 differ. A negative verdict completes the step: a vendor's RED or declined comes back in the body for the rules to decide on, while failed is for a vendor that could not do the work, and fails the run.

schemeFieldsThe vendor sends
hmacheader, key, algorithm (sha256 by default, sha1 or sha512), keyEncoding (utf8 by default, base64 or hex), encoding (hex by default, or base64), prefixin header, after prefix, the HMAC of the body as it came: Sumsub's X-Payload-Digest, Veriff's X-HMAC-Signature, GitHub's X-Hub-Signature-256 with the prefix sha256=
hmac-timestampedheader, key, keyEncoding, toleranceSeconds (300 by default, 30 to 3,600)in header, t=<seconds>,v1=<hex>, the HMAC-SHA256 of t, a . and the body, with several v1 while it rotates its secret: Stripe's Stripe-Signature, Persona's Persona-Signature
standard-webhookskey, toleranceSecondsStandard Webhooks: webhook-id, webhook-timestamp and webhook-signature, v1,<base64> of the HMAC-SHA256 of the id, the timestamp and the body joined by ., under the key the whsec_ secret encodes; or the same headers named svix-
tokenheader, key, prefixa shared token in header after prefix (Bearer with its space), never in the query, since request logs keep URLs; it signs nothing, so it authenticates the sender only
  • The key is one secret variable, <<webhookSecret>>, stored on the install as every secret is; a key that names no secret variable fails every call and webhook. The host checks the signature (hmac_verify), so the module never holds the key, and compares a token there too, in constant time.
  • A request that does not verify is rejected, 401 webhook_rejected: without the header, with another signature, or with a timestamp further than toleranceSeconds from now. A key whose text does not decode as keyEncoding says fails every webhook, 502 app_webhook_failed (the key is not base64). One that decodes but to other bytes than the vendor signs with, such as a base64 secret read as utf8, the default, rejects every webhook, 401 webhook_rejected.

What a webhook does ​

The API finds the install by its URL and the session by the external id, read from ?externalId= when the URL carries it and otherwise where webhook.externalId says. The module then:

  1. checks the signature, and rejects the request when it does not verify;
  2. reads the body as JSON, and rejects one that is not;
  3. reads the status where status.at says, and ignores a value in none of its lists (202 { ignored: true }; nothing changes);
  4. reads the external id where webhook.externalId says, under a scheme that signs only from what the request's signature covers, and rejects a request without one, or whose ?externalId= names another session;
  5. reads the event id and answers the event: completed with { body }, failed with the reason, or pending. The API records each event id once, so a delivery of the same body for the same session is a duplicate (200 { duplicate: true }) and the same body for two sessions two events.

Whoever replays a signed request can change all of it but what the signature covers, such as its query and its unsigned headers. Hence:

  • The event id and the status come only from the body or a header the scheme signs (under standard-webhooks, the id and the timestamp of the set of headers it checked), never from the query: an event id read elsewhere would make a replay count as new, a status read elsewhere turn a failure into a success. A configuration that reads them elsewhere fails every call and webhook through the install, as with config: webhook.eventId: hmac does not sign the header X-Event-Id: read it from the JSON body.
  • The external id comes only from the body or a header the scheme signs as well, under every scheme but token: one read from the query or an unsigned header could be changed by whoever replays a signed request, to name another pending session of the install. The platform refuses to save a configuration that reads it elsewhere (400 validation_error, its issue at /webhook/externalId), and the module refuses one again in every call and webhook, as with config: webhook.externalId: hmac does not sign the query parameter session: read the external id from the signed JSON body. The module reads the id from the configured source, never from ?externalId= alone: a request whose ?externalId= names another session than its signed body is rejected, and the API refuses, for every app, an event that names another session than the one it found the request for (401 webhook_rejected). A vendor whose signed webhook names the session only in its URL needs an app of the tenant's own (What it does not do).
  • A token signs nothing, so under token the external id may come from the body, a header or the query: the token authenticates the sender only, and whoever holds one of the vendor's requests can send it again, naming another pending session of the install.

The module logs one debug line per webhook, http-api webhook, with the scheme and the outcome (completed, failed, pending, ignored, rejected or error), never a value. The install's view carries its webhookUrl once its configuration has a webhook, null before: give it to the vendor, in its dashboard or with each request as <<$callbackUrl>>.

Example: a vendor with an API key ​

A screening vendor that takes POST /v2/screenings with its key in X-API-Key. The install, named screening:

json
{
  "endpoint": "https://api.screening.example/v2",
  "headers": [{ "key": "Accept", "value": "application/json" }],
  "auth": { "authType": "api-key", "key": "X-API-Key", "value": "<<apiKey>>", "addTo": "HEADERS" },
  "variables": [
    { "key": "apiKey", "secret": true, "description": "The live key from the vendor's dashboard" },
    { "key": "lists", "value": "sanctions,pep" }
  ]
}

Through the API, with an administrator's token: the install is created disabled with its host approved, given its key, enabled and tried once.

bash
VERSION=$(curl -s $API/apps/http-api -H "authorization: Bearer $ADMIN_PAT" | jq -r .entry.latest.id)
CONFIG=$(cat screening.json)   # the configuration above
curl -s -X POST $API/app-installs -H "authorization: Bearer $ADMIN_PAT" -H 'content-type: application/json' \
  --data "{\"name\":\"screening\",\"versionId\":\"$VERSION\",\"enabled\":false,\"config\":$CONFIG,\"approveHosts\":[\"api.screening.example\"]}" \
  | jq -c '{hosts, missingSecrets}'
# {"hosts":["api.screening.example"],"missingSecrets":["apiKey"]}
curl -s -X POST $API/app-installs/screening/secrets/apiKey -H "authorization: Bearer $ADMIN_PAT" \
  -H 'content-type: application/json' --data '{"value":"..."}'
curl -s -X PUT $API/app-installs/screening -H "authorization: Bearer $ADMIN_PAT" -H 'content-type: application/json' \
  --data "{\"versionId\":\"$VERSION\",\"enabled\":true,\"config\":$CONFIG}" | jq -c '{enabled, missingSecrets}'
# {"enabled":true,"missingSecrets":[]}
curl -s -X POST $API/app-installs/screening/test -H "authorization: Bearer $ADMIN_PAT" \
  -H 'content-type: application/json' --data '{"action":"request","input":{"endpoint":"/status"}}' | jq .status

A workflow step screens the applicant's company and keeps the vendor's verdict:

json
{
  "id": "screen",
  "type": "call_app",
  "app": "screening",
  "action": "request",
  "input": {
    "endpoint": "/screenings",
    "method": "POST",
    "params": [{ "key": "lists", "value": "<<lists>>" }],
    "body": {
      "contentType": "application/json",
      "body": "{\"name\": \"<<name>>\", \"country\": \"<<country>>\", \"threshold\": <<threshold>>}"
    },
    "variables": { "threshold": 0.85 },
    "pick": ["hit", "matches"]
  },
  "inputMapping": {
    "variables.name": "submission.legalName",
    "variables.country": "submission.country"
  },
  "outputKey": "screening",
  "next": "rules"
}

The vendor receives POST https://api.screening.example/v2/screenings?lists=sanctions%2Cpep with x-api-key and accept, content-type: application/json, and a body in which the name is escaped text and the threshold a number. A rule then fails on screening.body.hit.

Example: an HMAC over the request ​

A vendor in Sumsub's style: an app token in X-App-Token, the time in X-App-Access-Ts, and in X-App-Access-Sig the hex HMAC-SHA256, under a secret key, of the time, the method, the path with its query and the body. The install, named idv:

json
{
  "endpoint": "https://api.idv.example",
  "headers": [
    { "key": "X-App-Token", "value": "<<appToken>>" },
    { "key": "X-App-Access-Ts", "value": "<<$timestamp>>" }
  ],
  "auth": {
    "authType": "aletheia-hmac",
    "header": "X-App-Access-Sig",
    "algorithm": "sha256",
    "key": "<<secretKey>>",
    "keyEncoding": "utf8",
    "encoding": "hex",
    "prefix": "<<$timestamp>><<$method>><<$target>>"
  },
  "variables": [
    { "key": "appToken", "secret": true },
    { "key": "secretKey", "secret": true }
  ]
}

A step creates the vendor's applicant for the run's subject:

json
{
  "id": "applicant",
  "type": "call_app",
  "app": "idv",
  "action": "request",
  "input": {
    "endpoint": "/resources/applicants",
    "method": "POST",
    "params": [{ "key": "levelName", "value": "basic-kyc-level" }],
    "body": {
      "contentType": "application/json",
      "body": "{\"externalUserId\": \"<<subjectId>>\"}"
    },
    "pick": ["id", "review.reviewStatus"]
  },
  "inputMapping": { "variables.subjectId": "subjectId" },
  "outputKey": "applicant",
  "next": "rules"
}

<<$timestamp>> is one value for the call, so the header and the signed text agree. The host fills <<$method>> and <<$target>> with POST and /resources/applicants?levelName=basic-kyc-level as sent, signs that text followed by the body bytes it sends, and puts the signature in x-app-access-sig; the module never sees the key or the signature. The step is a POST without an idempotency header, so a 5xx fails it for good rather than creating the applicant twice.

Example: documents and a review that answers later ​

The idv vendor of the previous example also takes the applicant's documents, and reviews them later: it posts each change to a webhook, signed with an HMAC of the body in X-Payload-Digest, and names the applicant by the externalUserId it was created with. Its install turns documents on and sets up the webhook, its variables gaining the webhook's secret:

json
{
  "documents": true,
  "variables": [
    { "key": "appToken", "secret": true },
    { "key": "secretKey", "secret": true },
    { "key": "webhookSecret", "secret": true }
  ],
  "webhook": {
    "externalId": { "json": "/externalUserId" },
    "status": {
      "at": { "json": "/type" },
      "completed": ["applicantReviewed"],
      "pending": ["applicantPending", "applicantOnHold"]
    },
    "pick": ["type", "applicantId", "reviewResult"],
    "signature": {
      "scheme": "hmac",
      "header": "X-Payload-Digest",
      "algorithm": "sha256",
      "key": "<<webhookSecret>>"
    }
  }
}

With webhookSecret stored on the install and its webhookUrl given to the vendor, three steps run between collect and rules:

  1. applicant creates the vendor's applicant as in the previous example, with "externalUserId": "<<$idempotencyKey>>" in its body, "pick": ["id", "externalUserId"] and "next": "passport". The step's idempotency key is unique to the run, the step and the round, so the review that names it is the install's only session with that id; the subject's id would come again with a re-run or a later round.
  2. passport uploads the document the applicant gave, in a multipart body (the step of Documents in a body).
  3. review asks for the review and waits for it, two days at most:
json
{
  "id": "review",
  "type": "call_app",
  "app": "idv",
  "action": "start",
  "input": {
    "endpoint": "/resources/applicants/<<applicantId>>/status/pending",
    "method": "POST",
    "externalId": { "from": "value", "value": "<<externalUserId>>" },
    "callbackTimeoutSeconds": 172800
  },
  "inputMapping": {
    "variables.applicantId": "applicant.body.id",
    "variables.externalUserId": "applicant.body.externalUserId"
  },
  "outputKey": "review",
  "next": "rules"
}

The vendor's applicantPending keeps the step waiting, its applicantCreated and other types are ignored, and its applicantReviewed completes the step: review.body.reviewResult.reviewAnswer holds GREEN or RED, and a rule fails on RED. A webhook signed with another key is rejected, and so is one whose ?externalId= names another applicant's session.

Example: AWS Signature Version 4 ​

An API behind Amazon API Gateway with IAM authorisation, signed for the execute-api service. The install, named risk-api:

json
{
  "endpoint": "https://a1b2c3d4e5.execute-api.eu-west-1.amazonaws.com/prod",
  "auth": {
    "authType": "aws-signature",
    "accessKey": "AKIAIOSFODNN7EXAMPLE",
    "secretKey": "<<awsSecretAccessKey>>",
    "region": "eu-west-1",
    "serviceName": "execute-api",
    "addTo": "HEADERS"
  },
  "variables": [{ "key": "awsSecretAccessKey", "secret": true }],
  "acceptStatuses": [404]
}

The access key id is not a secret and is written as it is; the secret access key is a secret variable, as every credential is, and with temporary keys serviceToken names the session token's variable. A rule fails when the API knows the customer as high risk, and passes when it does not know the customer at all, which it answers with an accepted 404:

json
{
  "app": "risk-api",
  "action": "request",
  "input": { "endpoint": "/customers/<<customerId>>/risk" },
  "inputMapping": { "variables.customerId": "subject.customerId" },
  "resultPath": "body.level",
  "failWhen": "high"
}

The host signs the final request (its method, path, query, headers and body) for each request, adding authorization and x-amz-date; a request that sets either itself is refused.

Example: a key in the path and the body ​

A lookup vendor that takes its key as a path segment for one lookup, GET /v3/<key>/lookup?q=…, and inside the JSON body for a batch. The install, named lookup, lets steps use the key in those two places and no others:

json
{
  "endpoint": "https://api.lookup.example",
  "variables": [{ "key": "apiKey", "secret": true, "stepPlaces": ["path", "body"] }]
}

A step looks up the applicant's address:

json
{
  "id": "address",
  "type": "call_app",
  "app": "lookup",
  "action": "request",
  "input": {
    "endpoint": "/v3/<<apiKey>>/lookup",
    "params": [{ "key": "q", "value": "<<address>>" }],
    "pick": ["results.0"]
  },
  "inputMapping": { "variables.address": "submission.address" },
  "outputKey": "address",
  "next": "rules"
}

A batch puts the key in its body instead, inside a string:

json
{
  "endpoint": "/v3/batch",
  "method": "POST",
  "body": {
    "contentType": "application/json",
    "body": "{\"key\": \"<<apiKey>>\", \"queries\": <<addresses>>}"
  }
}

The host fills the key in percent-encoded in the path and escaped for its JSON string in the body; the module never sees it, and a failure names the path as HTTP 404 GET /v3/<<apiKey>>/lookup: …. A step that puts the key in a header or a query parameter fails before anything is sent.

Paste and import from Hoppscotch ​

The console's editors take Hoppscotch's JSON, so a request is built and tried in Hoppscotch, then pasted. Each paste says what it filled and lists what it could not take.

  • An install of http-api (its page in Connect › Apps, Paste from Hoppscotch…) takes a request, a collection or an environment, each paste filling the draft further. An environment gives its variables; a collection its auth, headers and variables, its requests being for steps; a request its method, headers and auth, its query and parameters, its request variables as plain variables and its URL as the endpoint, its body being for steps. The endpoint is the base URL a leading variable holds (<<baseUrl>>/v2/applicants: paste the environment that defines baseUrl first), or else the URL's origin, and the draft's endpoint stays when the URL is under it. A secret variable comes without a value: Hoppscotch's exports leave secrets' values out, and the console keeps none. A credential that Hoppscotch's auth held as text is not copied either: the field names a secret variable instead (password, token, apiKey, awsSecretKey, awsSessionToken, hawkKey, jwtSecret, jwtPrivateKey or akamaiClientSecret), and the paste lists it. Store each secret's value on the install afterwards.
  • A step or a rule through an http-api install (the input editor, Admin console: Define) takes one request and makes it the step's input: its URL as a path under the install's endpoint, or after a leading variable such as <<baseUrl>>, taken to stand for that endpoint; its query and parameters, method, headers and body, a multipart body's text parts included (with their content types, at most 20); its request variables as literal variables, which inputMapping overrides; and its auth as inherit or none. Hoppscotch's export leaves a file out, so a multipart body's file parts and a binary body are listed, to add as document parts or a binary body named by a variable that holds the document's id (Documents in a body).

Not taken: pre-request and test scripts (nothing runs scripts), OAuth 2.0 whatever the grant, GraphQL requests, the files of a multipart or binary body, a request to another host than the install's, a header the app keeps for itself, a variable whose name is not a letter followed by letters, digits or _, and in a step any auth but inherit and none, since the vendor's auth belongs to the install. An akamai-eg auth's nonce and timestamp are listed as dropped. The console reads Hoppscotch's JSON as Hoppscotch 2026.9.0 writes it (requests up to version 17, collections up to 12, environments up to 2) and refuses a newer version.

In Hoppscotch, a collection's menu exports it as Hoppscotch JSON, its requests in its requests, and an environment exports as JSON. Hoppscotch imports Postman and Insomnia collections and environments, OpenAPI documents, HAR files and cURL commands, so a request from any of them comes in through Hoppscotch: import it there, try it, export it, paste it.

What it does not do ​

http-api sends one request per call and answers with what came back, or waits for the install's webhook. For anything else, a tenant writes an app of its own (Apps: authoring):

  • The hand-off of an applicant to the vendor's own page: the manifest declares none.
  • A document inside a JSON body, as base64: a document goes as a multipart part or as a binary body.
  • A webhook signed with a public key (RSA, ECDSA, a JWS), sent as a form, or carrying several events: the install's webhook checks an HMAC or a token over one JSON event.
  • A signed webhook that names its session only in its URL (a query parameter the vendor adds to the callback address) or in a header its signature does not cover: under a scheme that signs, the install reads the external id only from what the signature covers.
  • OAuth 2.0, client credentials included: no such auth; an app of the tenant's own can fetch a token itself and keep it between calls (Keep a vendor's token between calls).
  • Several requests in one call, such as a token fetched before the call it authorizes, or a result polled until it is ready: one request per step, or an app of the tenant's own.
  • Scripts and redirects, which are not followed.

Those the platform plans are on the roadmap.

How the catalogue is built and published ​

The catalogue is part of the release: its apps are built from the repository with the release's SDK and carried in the API image, and the API publishes them when it starts.

  1. Build. Each catalog/<name> package (@aletheia-dev/app-<name>, private) builds with scripts/catalog-build.mjs: the SDK's build with the pinned tools that pnpm tools:extism installs under .tools/ (extism-js v1.7.0 and binaryen version_133, checksummed), into dist/app.wasm and dist/app.json. A package's tests run its built module, so its test task depends on its own build. The same script builds the test fixtures under tests/apps/, which are never indexed or carried in an image. catalog/http-api keeps the subset of Hoppscotch's schemas it reads in src/hoppscotch/, adapted to zod under Hoppscotch's MIT licence, whose notice heads each file; its tests run the built module through the real host against a vendor stub. The model apps share their code in packages/model-apps (@aletheia-dev/model-apps, private): each catalog/<name> package names its vendor, connections and hosts, and the shared tests run its built module against a stub of every vendor it reaches.
  2. Index. pnpm catalog:index copies every package's module to catalog/dist/<name>.wasm and writes catalog/dist/index.json: per app its name, version, SHA-256, size, manifest and file name, and the source it was built from when the build gives REVISION (the commit, as the image build stamps it; the default unknown gives none) and optionally REPOSITORY. It fails, naming the package, when one has no build: the catalogue is complete or not at all. Without a package under catalog/, or without catalog/, the index is an empty list.
  3. Image. The Dockerfile's compile stage runs both (it uses a newer Debian than the runtime images, because extism-js needs glibc 2.39), and the API image carries catalog/dist at /app/catalog with APP_CATALOG_DIR=/app/catalog (Deployment).
  4. Publish. At start-up, before it listens, the API reconciles the index with the platform versions it has: a version is recorded for each entry it does not have yet, the module is uploaded to object storage when storage has no object for its hash (after checking that its memory maximum is the one its manifest names), a withdrawn version of the release's that the index carries again is published again, and a published version of the release's that the index no longer carries is withdrawn (installs keep running it). An empty index therefore withdraws every published version of the release's, while no index at all (APP_CATALOG_DIR unset, or no index.json in it) publishes and withdraws nothing. A build is not byte-reproducible (extism-js snapshots the runtime with its random seeds), so an image built again carries the same app version with another module: when its manifest is the published one, the published module stays and the new build is unused. An entry whose name and version the release already published with another manifest stops the start-up: a changed app needs a new version number. The versions the operator published or withdrew outside a release (platformSource operator, Operations) are the operator's: the start-up never withdraws one nor publishes again one the operator withdrew. An entry that is one of them is adopted, with the operator's module, when its manifest is the same; with another manifest the operator's version stays, and the release's build of it is skipped, logged at error and counted in conflicts, without stopping the start-up. A version whose published module is the index's build records the index's source as its provenance (release: the commit and repository, trusted as the image is), unless it has provenance already. Without object storage or an app runner the API skips the catalogue and runs without apps. Once it listens, the API sweeps the installs that upgrade automatically onto what the catalogue now publishes, and sweeps again every five minutes, so a version the operator publishes or withdraws outside a release reaches them too.

In development the same index comes from pnpm catalog:index, with APP_CATALOG_DIR pointing at catalog/dist (Get started), once the catalogue's packages are built with pnpm tools:extism and pnpm turbo run build --filter='./catalog/*'. Adding an app to the catalogue is a pull request: pnpm app:new <name> scaffolds catalog/<name>, and the hosts it declares, with the config fields whose URLs add an install's own (hostsFromConfig), are reviewed with it. The catalogue keeps to what every tenant can use, like http-api and the model apps, rather than an app per data vendor.

Released under the Apache-2.0 License.