Building plugins
A field plugin is an HTTPS service you run that answers one kind of question inside allme: a searchable pick, a chain of picks, typed values, and the outputs your service computes from them. A company adds your plugin in its portal and places one of its field types on a contract-flow step, on a service request row or on a declared sign-in claim. The person's client talks to your plugin, sealed end to end, while they fill the field in.
How a plugin fits in
- A company adds it. In the allus portal, Configuration › Plugins: an address (your
base_url) and an optional token — or one click on a plugin from the platform's built-in list. The platform fetches your manifest and lists your field types. - The company places a field type on a flow step, a request row or a sign-in claim, and wires each of your inputs to one of its own values.
- A person fills the field in. Their client seals each request to your public key and posts it through the platform's forwarder (
integrations.allme.fyi/plugins), the only component that opens a connection to your plugin. You answer sealed to a key of that client. The forwarder passes both directions through unopened; the allme API never sees a plugin request or reply. - The answer is stored like any other answer — the labels and ids of the picks, the typed block values and your outputs, in one self-describing JSON string. Reading it back never calls your plugin again.
Your plugin is the company's processor
The company decides which of its values feed your plugin. Your plugin receives exactly:
- the field type and the block asking,
- the search text the person types and the picks and typed block values so far,
- the input values the company wired to your inputs,
- a reply key to seal the answer to.
It never receives the person's name, their share code, or a user, run, service or connection id. Search text and picks reach your plugin while the person types, before they submit anything.
- A person's own private value reaches a plugin only after they allow it on that screen: the client names your plugin, its host and the private fields before anything private is sent. When they decline, a required private input keeps the field disabled and an optional one is left out of the call.
- Another party's private value is never sent — on any surface, by any client: the apps, the portal and the SDKs alike.
The two endpoints
A plugin is two endpoints under its base_url, both HTTPS, both answered as JSON:
| Endpoint | Timeout | Largest answer |
|---|---|---|
GET {base_url}/manifest | 1 s | 256 KB |
POST {base_url}/call | 3 s | 256 KB |
Every request carries X-Allme-Plugin-Protocol: 1. When the company set a token, every request also carries Authorization: Bearer <token>, exactly as the company entered it.
Authorization header does not carry it.The address has to be reachable the way the forwarder connects:
httpson port 443, with a certificate that passes normal TLS peer and host verification. No user name or password in the address.- A host that resolves to public addresses only. Every resolved address is checked; private, loopback, link-local, shared, documentation, benchmarking, multicast and reserved ranges (IPv4 and IPv6) are refused, and an IPv6 address that embeds an IPv4 address is checked as that IPv4 address. The connection is pinned to the checked address.
- Not a host of the platform's own (
allme.fyi,allus.fyior a subdomain) — those are reserved for the built-in plugins. - No redirects are followed. Answer at the address itself.
GET /manifest — describe your plugin
The manifest names your plugin, publishes your public key and declares your field types. The platform fetches it when a company adds your plugin, when it presses Refresh description, and when a call finds your key has changed (below). A company's flows, request rows and claims copy the field type's description when they are saved or published.
{
"protocol": 1,
"name": "Flex",
"public_key": "<base64 SPKI (DER) of your RSA-2048 public key>",
"field_types": [
{
"key": "cao",
"label": "CAO",
"inputs": [
{"key": "age_at_start", "type": "number", "label": "Age at start", "required": true},
{"key": "start_date", "type": "date", "label": "Start date", "required": false}
],
"blocks": [
{"key": "cao", "kind": "search_select", "label": "CAO"},
{"key": "scale", "kind": "search_select", "label": "Scale", "depends_on": "cao"},
{"key": "step", "kind": "search_select", "label": "Step", "depends_on": "scale"}
],
"outputs": [
{"key": "min_wage", "type": "number", "label": "Minimum wage"},
{"key": "agreement_wage", "type": "number", "label": "Agreement wage"}
]
}
]
}
The whole manifest is refused on any failure, and the company sees the reason in its portal:
protocolis1.public_keyis the base64 SPKI (DER) of an RSA public key of exactly 2048 bits.field_typesholds 1–20 entries; each has 1–10blocks, 0–10inputsand 0–10outputs.- Every
keymatches^[a-z][a-z0-9_]{0,39}$and is unique within its list. Block keys and output keys share one namespace per field type, and neither may beid. - An input or output
typeis one oftext,number,date,boolean. An input'srequiredistrueorfalse. - A block
kindis one ofsearch_select,text,number,date.depends_on, when present, names an earliersearch_selectblock. nameand everylabelare 1–100 characters.
The four blocks
A plugin never ships UI code: every allme client draws only these blocks, in the order you declare them, and shows your outputs under them.
kind | What the person does | What reaches your plugin |
|---|---|---|
search_select | Types to search; picks one option from the list you serve. With depends_on its options follow the earlier pick, and changing a pick clears every block that depends on it, directly or through another block. | The search text as query; the picked option's id in picks |
text | Types a value | A string in values |
number | Types a number | A JSON number in values |
date | Picks a date | A YYYY-MM-DD string in values |
Inputs and their types
Inputs are values the company already holds, wired by the company to your inputs: on a flow, a field, a plugin answer key or a constant answered on an earlier step or the same step; on a request row, another request row of the same service; on a sign-in claim, another claim of the same app. An input's declared type must match its source: a number input takes a number source, date takes a date, boolean takes a boolean, and text takes any source. Inputs arrive typed:
| Declared type | In the request |
|---|---|
number | a JSON number |
date | a YYYY-MM-DD string |
boolean | a JSON boolean |
text | a string |
A required input is always present — while one is unavailable the client does not call your plugin and shows the field disabled with the reason. An optional input may be absent. When an input changes after picks exist, the client calls outputs again with the same picks.
POST /call — answer a question
The body is {"request": "<wrapper string>"}: the request, sealed to the public key in your manifest. Opened, it reads:
{
"field_type": "cao",
"op": "options",
"block": "scale",
"query": "sch",
"picks": {"cao": "hrc"},
"values": {},
"inputs": {"age_at_start": 19, "start_date": "2026-10-01"},
"reply_key": "<base64 SPKI of the caller's reply key>"
}
| Member | Meaning |
|---|---|
field_type | One of your field type keys. |
op | options — list the options of one block; outputs — compute the outputs. |
block | op = options only: the block asking. |
query | op = options on a search_select block: the search text. "" lists everything. |
picks | The ids picked so far, by block key. |
values | The typed block values so far, by block key. |
inputs | The wired input values, by input key, typed. |
reply_key | The public half of a key pair the caller made. Seal your answer to it. |
Answer 200 {"reply": "<wrapper string sealed to reply_key>"}. The reply plaintext is one of:
| For | Reply plaintext |
|---|---|
op = options | {"options": [{"id": "…", "label": "…"}], "more": false} — at most 50 options; more: true says the list was cut and a longer query narrows it. |
op = outputs | {"outputs": {"min_wage": 9.5, "agreement_wage": 14.2}} — one value per output key, of its declared type, or null when you have none. |
op = outputs, the picks no longer fit | {"picks_invalid": true} — the picks no longer fit the current inputs or each other. The client clears the picks and outputs and the person picks again. |
A request you cannot open — sealed to a key you no longer hold — is answered 409 {"error":"key_unknown"}. The forwarder then fetches your manifest once, hands the caller your current public key, and the caller reseals once. A second key_unknown is "not responding". Replacing your key therefore needs nothing but publishing the new one in your manifest.
Any other non-200 answer, a timeout, or an answer over 256 KB is "not responding". The person sees that on this field and can still answer every other field.
Sealing, both ways
Both directions use the platform's standard wrapper — the same one every encrypted allme value uses:
{
"_enc": 1,
"k": "base64( RSA-OAEP( aes_key ) )",
"iv": "base64( 12-byte IV )",
"d": "base64( AES-256-GCM ciphertext, 16-byte tag appended )"
}
- RSA-OAEP with SHA-256 and MGF1-SHA-256 wraps a fresh 32-byte AES key. Pin both hashes explicitly — a library default of SHA-1 does not open these.
- AES-256-GCM with a 12-byte IV encrypts the plaintext; the 16-byte tag is appended to
d. - The request is sealed to your manifest's
public_key; your reply is sealed to the request'sreply_key. Therequestandreplymembers carry the wrapper as a JSON string.
Make your key pair once and keep the private half on your server. With OpenSSL:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out plugin-key.pem # unencrypted PKCS#8
openssl pkey -in plugin-key.pem -pubout -outform DER | base64 | tr -d '\n' # your manifest's public_key
The builder routine in the SDKs
Each SDK carries the sealing for your plugin's server as two standalone functions: open a request — the /call body, your private key PEM and its passphrase (or none for an unencrypted PKCS#8 PEM) in, the request object out — and seal a reply — the reply plaintext and the request's reply_key in, the response body {"reply": …} out. They are functions for your plugin's own server, never calls on the allme API, and they need no allme credentials.
# Flask
from flask import Flask, abort, jsonify, request
from allus_company_data import DecryptError, plugin_open_request, plugin_seal_reply
app = Flask(__name__)
KEY = open("plugin-key.pem", "rb").read() # unencrypted PKCS#8
@app.post("/call")
def call():
if request.headers.get("Authorization") != f"Bearer {TOKEN}":
abort(401)
try:
req = plugin_open_request(request.get_data(), KEY, None)
except DecryptError:
return jsonify(error="key_unknown"), 409 # not sealed to the key this plugin holds
if req["op"] == "options":
reply = {"options": search(req["block"], req.get("query", ""), req["picks"]), "more": False}
else:
reply = {"outputs": compute(req["picks"], req["values"], req["inputs"])}
return jsonify(plugin_seal_reply(reply, req["reply_key"]))// Express
import express from 'express';
import { readFileSync } from 'node:fs';
import { DecryptError, pluginOpenRequest, pluginSealReply } from '@allus-fyi/company-data';
const KEY = readFileSync('plugin-key.pem'); // unencrypted PKCS#8
const app = express();
app.post('/call', express.raw({ type: '*/*' }), (req, res) => {
if (req.get('authorization') !== `Bearer ${TOKEN}`) return res.sendStatus(401);
let call;
try {
call = pluginOpenRequest(req.body, KEY, null);
} catch (e) {
if (e instanceof DecryptError) return res.status(409).json({ error: 'key_unknown' });
throw e;
}
const reply = call.op === 'options'
? { options: search(call.block, call.query ?? '', call.picks), more: false }
: { outputs: compute(call.picks, call.values, call.inputs) };
res.json(pluginSealReply(reply, call.reply_key as string));
});// net/http
var key, _ = os.ReadFile("plugin-key.pem") // unencrypted PKCS#8
func call(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
body, _ := io.ReadAll(r.Body)
req, err := companydata.PluginOpenRequest(body, key, "") // "" = unencrypted PEM
if err != nil {
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]string{"error": "key_unknown"})
return
}
var reply map[string]any
if req["op"] == "options" {
reply = map[string]any{"options": search(req), "more": false}
} else {
reply = map[string]any{"outputs": compute(req)}
}
out, err := companydata.PluginSealReply(reply, req["reply_key"].(string))
if err != nil {
http.Error(w, "seal failed", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(out)
}// ASP.NET minimal API
using Allus.CompanyData;
var key = File.ReadAllText("plugin-key.pem"); // unencrypted PKCS#8
app.MapPost("/call", async (HttpRequest http) =>
{
if (http.Headers.Authorization != $"Bearer {token}") return Results.Unauthorized();
var body = await new StreamReader(http.Body).ReadToEndAsync();
IReadOnlyDictionary<string, object?> req;
try { req = Crypto.PluginOpenRequest(body, key, null); }
catch (DecryptException) { return Results.Json(new { error = "key_unknown" }, statusCode: 409); }
object reply = (string?)req["op"] == "options"
? new { options = Search(req), more = false }
: new { outputs = Compute(req) };
return Results.Json(Crypto.PluginSealReply(reply, (string)req["reply_key"]!));
});// Servlet
import fyi.allme.allus.companydata.Crypto;
import fyi.allme.allus.companydata.DecryptException;
byte[] key = Files.readAllBytes(Path.of("plugin-key.pem")); // unencrypted PKCS#8
protected void doPost(HttpServletRequest http, HttpServletResponse resp) throws IOException {
if (!("Bearer " + token).equals(http.getHeader("Authorization"))) { resp.setStatus(401); return; }
String body = new String(http.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
Map<String, Object> req;
try {
req = Crypto.pluginOpenRequest(body, key, null);
} catch (DecryptException e) {
resp.setStatus(409);
resp.getWriter().write("{\"error\":\"key_unknown\"}");
return;
}
Object reply = "options".equals(req.get("op"))
? Map.of("options", search(req), "more", false)
: Map.of("outputs", compute(req));
resp.setContentType("application/json");
resp.getWriter().write(toJson(Crypto.pluginSealReply(reply, (String) req.get("reply_key"))));
}use Allus\CompanyData\Crypto\Crypto;
use Allus\CompanyData\Errors\DecryptError;
$key = file_get_contents('plugin-key.pem'); // unencrypted PKCS#8
if (($_SERVER['HTTP_AUTHORIZATION'] ?? '') !== "Bearer {$token}") {
http_response_code(401);
exit;
}
header('Content-Type: application/json');
try {
$req = Crypto::pluginOpenRequest(file_get_contents('php://input'), $key, null);
} catch (DecryptError) {
http_response_code(409);
echo json_encode(['error' => 'key_unknown']);
exit;
}
$reply = $req['op'] === 'options'
? ['options' => search($req), 'more' => false]
: ['outputs' => (object) compute($req)];
echo json_encode(Crypto::pluginSealReply($reply, $req['reply_key']));| SDK | Open a request | Seal a reply | Reply key pair · SPKI export |
|---|---|---|---|
| Python | plugin_open_request(body, private_key_pem, passphrase) | plugin_seal_reply(reply, reply_key_spki) | generate_reply_key_pair() · export_public_key_spki(key) |
| TypeScript | pluginOpenRequest(body, privateKeyPem, passphrase) | pluginSealReply(reply, replyKeySpki) | generateReplyKeyPair() · exportPublicKeySpki(key) |
| Go | PluginOpenRequest(body, privateKeyPEM, passphrase) | PluginSealReply(reply, replyKeySPKI) | GenerateReplyKey() · ExportPublicKeySPKI(pub) |
| C# | Crypto.PluginOpenRequest(body, privateKeyPem, passphrase) | Crypto.PluginSealReply(reply, replyKeySpki) | Crypto.GenerateReplyKey() · Crypto.ExportPublicKeySpki(key) |
| Java | Crypto.pluginOpenRequest(body, privateKeyPem, passphrase) | Crypto.pluginSealReply(reply, replyKeySpki) | Crypto.generateReplyKey() · Crypto.exportPublicKeySpki(key) |
| PHP | Crypto::pluginOpenRequest($body, $privateKeyPem, $passphrase) | Crypto::pluginSealReply($reply, $replyKeySpki) | Crypto::generateReplyKeyPair() · Crypto::exportPublicKeySpki($key) |
Every SDK's private-key loader accepts an unencrypted PKCS#8 PEM (pass no passphrase — None, null, or "" in Go) as well as an encrypted one. The reply key pair is what a caller makes; a plugin needs it only to test its own endpoint.
Limits
| Limit | Value |
|---|---|
| Connect | 1 s |
A call (/call), in total | 3 s |
A manifest (/manifest), in total | 1 s |
| A reply or manifest body | 256 KB |
| Calls in flight, per plugin | 10 |
| Calls in flight, per person and plugin | 2 |
| Calls per person and plugin | 60 a minute |
The forwarder counts all of them. A plugin that breaks a limit is "not responding" for that call, and the person can still answer every other field.
What is stored — and what is not signed
The answer of a plugin field is this self-describing JSON string, sealed like any other answer of its surface — per party on a flow, to the service key on a request row, to the app key on a sign-in claim:
{ "plugin": "Flex", "type": "cao",
"blocks": [{"key":"cao", "kind":"search_select", "label":"CAO", "id":"hrc", "value":"Horeca Fictief"},
{"key":"scale", "kind":"search_select", "label":"Scale", "id":"c", "value":"Schaal C"},
{"key":"step", "kind":"search_select", "label":"Step", "id":"4", "value":"Trede 4"}],
"outputs": [{"key":"min_wage", "type":"number", "label":"Minimum wage", "value":9.5},
{"key":"agreement_wage", "type":"number", "label":"Agreement wage", "value":14.2}] }
blocks is in declared order: a search_select block carries the picked id and the option's label as value; a typed block carries its typed value and no id. An answer without an outputs array is unfinished. On a flow the answer is readable in conditions, constants, defaults, bounds and PDF tags as cao (the blocks' values joined by / ), cao.scale, cao.scale.id and cao.min_wage — see Contract flows.
The security rules
- Your private key stays on your server. The allme API, the database and the portal never hold a plugin's private key, and the forwarder never opens what it carries.
- Only the forwarder connects to your plugin, from the platform's integrations host, through the address rules above.
- The token is the company's. It is stored as entered, shown masked (the last four characters) in every portal response, and sent only as the
Authorizationheader of the forwarder's requests. - Private values follow the processor rules: a person's own after an explicit allow on that screen, another party's never.
- Every member of a request is caller input. Validate the field type, the block, the picks and the inputs before you use them.
Worked example: flex
flex is the plugin the platform hosts itself, at https://integrations.allme.fyi/flex, without a token. A company switches it on with one click from the built-in list in Configuration › Plugins. It serves made-up test data only.
- One field type,
cao— its manifest is the example above: inputsage_at_start(number, required) andstart_date(date, optional), blocks CAO → scale → step, outputsmin_wageandagreement_wage, both euros per hour. - Options.
caolists the CAOs,scalethe picked CAO's scales andstepthe picked scale's steps — each narrowed to the labels that contain the query, case-insensitive (an empty query lists all). - Outputs need all three picks.
agreement_wageis the picked step's hourly wage.min_wagedepends on the whole years ofage_at_start: 0 up to age 13, age / 3 for 14–18 and age / 2 for 19–21, rounded to two decimals, andnullabove 21.start_dateis accepted and unused. - A missing pick, or picks that do not chain — a scale not of the picked CAO, a step not of the picked scale — answer
{"picks_invalid": true}. - A request sealed to a key flex no longer holds answers
409 {"error":"key_unknown"}; any other malformed request answers400 {"error":"bad_request"}.
On a flow, a company wires age_at_start to an age field, then sets a wage field's Default to max(cao.min_wage, cao.agreement_wage) and its Min to cao.min_wage; a PDF template prints {{cao}} and {{cao.agreement_wage}}.
Next
- Contract flows — plugin elements, dotted refs, defaults and bounds, and answering a plugin field on the company's own turn.
- Service API reference — plugin request rows in the request-field catalog.
- Sign in with allme — plugin claims and
parsePluginValue. - Encryption & data handling — the wrapper in depth.