Appearance
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: []andhostsFromConfig: ['/endpoint']: each install calls the host of itsendpointand no other, approved when the install is saved (Hosts).secrets: []andsecretsFromConfig: '/variables': the install's secret variables are its secrets (Secret variables).needs: ['documents']anddocumentsFromConfig: '/documents': a call reads the tenant's documents only through an install whosedocumentsistrue, and a run's call only its subject's (Documents in a body).handles.webhook, withwebhookExternalIdFromConfig: '/webhook/externalId': the API reads a webhook's external id where the install'swebhook.externalIdsays, and an install without awebhookreceives none (The install's webhook).- Two actions,
requestandstart: 30 seconds per call,idempotent: falseand noretryof their own (Responses and retries), andmappable: ['/variables']: run data fills their variables only (Variables).startis asynchronous, withasync.callbackTimeoutSecondsof 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
| Field | Default | Notes |
|---|---|---|
endpoint | required | The 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. |
method | GET | The 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). |
timeoutMs | 10000 | The deadline of each request, 1,000 to 25,000 ms. A digest auth sends up to three requests, each with this deadline. |
idempotencyKeyHeader | none | The 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. |
documents | none (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. |
webhook | none | How 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.
authType | Fields | The host sends |
|---|---|---|
none | no auth | |
basic | username, password (credential) | authorization: Basic … |
bearer | token (credential) | authorization: Bearer … |
api-key | key, value (credential), addTo (HEADERS by default, or QUERY_PARAMS) | the header key: value, or the query parameter key=value |
digest | username, password (credential) | the request, and again with authorization: Digest … after the vendor's 401 challenge |
aws-signature | accessKey, secretKey (credential), region, serviceName, serviceToken (credential, a session token, optional), addTo | AWS Signature Version 4 over the request, in its headers or its query |
hawk | authId, authKey (credential), algorithm (sha256 by default, or sha1), includePayloadHash, ext, app, dlg | authorization: Hawk … |
jwt | algorithm (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-eg | accessToken, 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-hmac | header, algorithm (sha256 by default, sha1 or sha512), key (credential), keyEncoding (utf8 by default, base64 or hex), encoding (hex by default, or base64), prefix, suffix | an HMAC of prefix + the body as sent + suffix under the key, in header (Signing a request) |
- Every type takes
authActive,trueby default;falsesends no auth. aletheia-hmacis 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. Itsprefixandsuffixmay use<<$method>>,<<$target>>(the path and the query as sent) and<<$host>>, which the host fills as the request goes out (example).- A
jwt'spayloadandjwtHeadersare JSON text, as Hoppscotch keeps them ({}by default). The module fills their variables, as values in JSON, then parses them;iat,nbfandexpgiven as text ("now","5m","-30s") count from the time the host signs the token. akamai-egkeeps Hoppscotch's fields as text,maxBodySizeincluded, and leaves an empty one out. Itshostmay use non-secret variables and must be the endpoint's host, since the signature would cover a host the request never reaches;headersToSigncannot nameauthorizationor a header the host sets;maxBodySize, at most 16 MiB, must be the API client'smax_bodyat 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'srealm,nonce,algorithm,qop,nc,cnonce,opaqueanddisableRetry(the host answers the vendor's challenge itself), ahawk'suser,nonceandtimestampand anakamai-eg'snonceandtimestamp(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:
| Kind | Declared | Value | Used in |
|---|---|---|---|
| The install's, plain | in variables, { "key": "region", "value": "eu" } | its value | any text of the install or the step |
| The install's, secret | in variables, { "key": "apiKey", "secret": true }, without a value | the secret of that name stored on the install, which only the host fills in | the values of the install's params and headers, and its auth; in a step, where its stepPlaces allows (Secrets in a step) |
| The step's | in the step's variables, usually filled from run data (below) | any JSON value | any text of the install or the step |
| Built-in | none | <<$timestamp>> (Unix seconds), <<$isoTimestamp>>, <<$randomUUID>>, <<$idempotencyKey>> and <<$callbackUrl>>; one value each per call | any 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'sparamsorheaders, a step's input outside the places itsstepPlaceslists, ajwt'spayloadorjwtHeaders, analetheia-hmac'sprefixorsuffix, or a name such as anapi-key'skey) fails the call; the install'sendpointtakes 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
variablesonly (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:
| Place | The value |
|---|---|
A segment of the step's endpoint | percent-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 value | percent-encoded; a secret, in a value only, is encoded by the host once it is filled in |
| A header value | as 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 string | the 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, bare | the 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 body | as 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"] }stepPlaces | A step may use the secret in | The host fills it in |
|---|---|---|
path | a segment of its endpoint | percent-encoded, so a value holding / stays one segment; a value that is . or .. is refused, since the request would leave the path |
query | the value of one of its params | percent-encoded |
headers | the value of one of its headers | as it is |
body | its body: inside a JSON string, a text body, the value of a form field or of a multipart text part | escaped 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
stepPlacesdoes 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
stepPlaceslists, and a step'svariablesnever 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.
stepPlaceson a variable that is not secret fails the save with 400validation_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'svariablesonly, 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. ChangingstepPlacesadds no host, so the save applies at once, and the save's audit event records each secret's places assecretPlaces(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:
| Field | Default | Notes |
|---|---|---|
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. |
method | the install's | GET, POST, PUT, PATCH, DELETE or HEAD. |
params | [] | Query parameters, the install's kind of entries, at most 20. |
headers | [] | Headers, at most 20. |
body | none | { 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. |
acceptStatuses | the install's | The statuses answered as output besides 2xx, in place of the install's. |
pick | the whole body | At most 50 dot paths into the response body to keep, such as data.id (numeric segments index arrays). |
responseAs | auto | How the response body is read (Responses and retries). |
The body's content type decides how it is read and sent:
contentType | body | Sent as |
|---|---|---|
application/json, application/ld+json, application/hal+json, application/vnd.api+json | JSON text with variables | the 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/plain | text with variables | the text with its variables filled in |
application/x-www-form-urlencoded | Hoppscotch's key-value text: one key: value per line, a line starting with # kept without being sent | form 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-data | at most 20 entries, Hoppscotch's: { key, value, isFile: false, contentType?, active } for a text part, { key, isFile: true, document: "<<documentId>>", filename?, active } for a document | a 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-stream | document: "<<documentId>>" in place of body | the 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:
| Field | Default | Notes |
|---|---|---|
externalId | required | Where 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. |
callbackTimeoutSeconds | 604800 (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'sendpoint:https://api.vendor.example/v2and/customers/<<customerId>>givehttps://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 anapi-keyor ajwtauth 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 anapi-keyauth sets,x-amz-date,x-amz-security-tokenandx-amz-content-sha256, analetheia-hmac'sheader, theidempotencyKeyHeader, andhost,content-length,transfer-encoding,connection,expectandupgrade. - Body. The step's, never on
GETorHEAD; the install has none. Its content type replaces the install'scontent-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
authisnone. 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'stimeoutMs. 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 answers | The call |
|---|---|
2xx, or a status in acceptStatuses (the step's, else the install's) | answers the output below |
| 408, 425, 429 or 5xx | fails, 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 transit | fails, 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 }] }
}okistruefor 2xx; an accepted 404 answersok: false.headersare lower-cased:content-type,location,retry-after,x-request-idandetag, with the install'sresponseHeaders.set-cookieis never returned.bodybyresponseAs:autoreads JSON when the content type is JSON (application/jsonor a+jsontype) and the body parses, text otherwise;jsonreads JSON whatever the content type says, and fails the call when the body does not parse;textkeeps the text;base64answersbodyBase64instead, for binary content. An empty body gives neither.pickkeeps 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.pickneeds a JSON body: on text, or withresponseAstextorbase64, 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_appstep retries a retryable failure by its ownretry(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
apprule calls once: any failure makes the ruleerror. A rule cannot wait for a webhook, so it callsrequestonly:startis 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 throughinputMapping:"variables.passport": "submission.identityDocument.fileId", the id a collection flow'sfilefield 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
apprule 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
documentsis absent orfalse, 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 asapp.install.documents.enabledorapp.install.documents.disabled(Audit log). - Multipart. Hoppscotch's form entries. A text part's
valuetakes variables, a secret one where itsstepPlaceslistsbody, and itscontentTypeis 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, orfilename, which may use variables. An entry withactive: falseis 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'scontent-typeheader 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-hmacauth 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,startfails 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
apprule needs its answer at once, so it callsrequest; a rule that callsstartis 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" }.
| Field | Notes |
|---|---|
externalId | Required: 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. |
eventId | The 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. |
error | The 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. |
pick | At most 50 dot paths into the body a completed step keeps, as a step's pick reads them; the whole body without it. |
signature | Required: 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.
scheme | Fields | The vendor sends |
|---|---|---|
hmac | header, key, algorithm (sha256 by default, sha1 or sha512), keyEncoding (utf8 by default, base64 or hex), encoding (hex by default, or base64), prefix | in 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-timestamped | header, 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-webhooks | key, toleranceSeconds | Standard 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- |
token | header, key, prefix | a 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 thantoleranceSecondsfrom now. A key whose text does not decode askeyEncodingsays fails every webhook, 502app_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 asutf8, the default, rejects every webhook, 401webhook_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:
- checks the signature, and rejects the request when it does not verify;
- reads the body as JSON, and rejects one that is not;
- reads the status where
status.atsays, and ignores a value in none of its lists (202{ ignored: true }; nothing changes); - reads the external id where
webhook.externalIdsays, 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; - reads the event id and answers the event:
completedwith{ body },failedwith the reason, orpending. 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 withconfig: 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 (400validation_error, its issue at/webhook/externalId), and the module refuses one again in every call and webhook, as withconfig: 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 (401webhook_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
tokenthe 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 .statusA 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:
applicantcreates 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.passportuploads the document the applicant gave, in a multipart body (the step of Documents in a body).reviewasks 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 definesbaseUrlfirst), 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,jwtPrivateKeyorakamaiClientSecret), and the paste lists it. Store each secret's value on the install afterwards. - A step or a rule through an
http-apiinstall (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, whichinputMappingoverrides; and its auth asinheritornone. 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.
- Build. Each
catalog/<name>package (@aletheia-dev/app-<name>, private) builds withscripts/catalog-build.mjs: the SDK's build with the pinned tools thatpnpm tools:extisminstalls under.tools/(extism-js v1.7.0 and binaryen version_133, checksummed), intodist/app.wasmanddist/app.json. A package's tests run its built module, so itstesttask depends on its ownbuild. The same script builds the test fixtures undertests/apps/, which are never indexed or carried in an image.catalog/http-apikeeps the subset of Hoppscotch's schemas it reads insrc/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 inpackages/model-apps(@aletheia-dev/model-apps, private): eachcatalog/<name>package names its vendor, connections and hosts, and the shared tests run its built module against a stub of every vendor it reaches. - Index.
pnpm catalog:indexcopies every package's module tocatalog/dist/<name>.wasmand writescatalog/dist/index.json: per app its name, version, SHA-256, size, manifest and file name, and thesourceit was built from when the build givesREVISION(the commit, as the image build stamps it; the defaultunknowngives none) and optionallyREPOSITORY. It fails, naming the package, when one has no build: the catalogue is complete or not at all. Without a package undercatalog/, or withoutcatalog/, the index is an empty list. - Image. The Dockerfile's
compilestage runs both (it uses a newer Debian than the runtime images, because extism-js needs glibc 2.39), and the API image carriescatalog/distat/app/catalogwithAPP_CATALOG_DIR=/app/catalog(Deployment). - 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_DIRunset, or noindex.jsonin 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 (platformSourceoperator, 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 aterrorand counted inconflicts, without stopping the start-up. A version whose published module is the index's build records the index'ssourceas 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.