Theme
Open the portal

SDKs

Six official SDKs — Python, TypeScript, Go, C#/.NET, Java, PHP — share one identical API surface: point them at a JSON config file and they hand back typed, plaintext, your-slug-keyed conclusions. Decryption, the OAuth token, the field catalog, id plumbing, binary fetching, JSON-vs-XML, and the crash-safe changes pump are all hidden.

Each SDK turns the service API into a single object you construct from config. For every connected person you get a map of your request-field slug → typed plaintext value (plus whether it is live and when it last changed). Because the platform is zero-knowledge, all decryption happens inside the SDK with your service private key — and the person's own field choices are never exposed; you only ever see the request slots you configured.

Pick your language with the tabs in any code block below — your choice persists across every block and page.

Want to see one run? Each SDK ships a runnable example app — for PHP, Python and TypeScript it comes inside the package you install below. Start it, complete a short browser checklist, then click a scenario and watch the exact SDK calls and results.

What the SDK does for you

The canonical config.json

The config schema is identical across all six SDKs — the same snake_case keys everywhere. Any field can be overridden by an ALLUS_* env var (e.g. ALLUS_CLIENT_SECRET, ALLUS_KEY_PASSPHRASE), so secrets needn't live in the file. No SDK method ever takes a key, passphrase, or secret as an argument — they all come from here.

{
  "api_url": "https://api-eu.allme.fyi",
  "client_id": "svc_1a2b3c...",
  "client_secret": "...",
  "service_private_key": "./service-CRM.pem",
  "key_passphrase": "...",

  "account_private_key": "./account.pem",
  "account_passphrase": "...",

  "webhooks": {
    "wh_abc123": "hmac_secret_for_that_webhook"
  },

  "cache_dir": "./allus-cache",
  "format": "json"
}
KeyRequiredMeaning
api_urlyesThe global front door, e.g. https://api.allme.fyi. The SDK authenticates there, reads the home-region base off the token response and sends its data calls to that base — see Regions. You never configure a region.
client_id / client_secretyesThe registered client_credentials credentials for one service (see Authentication).
service_private_keyyesPath to the OpenSSL-encrypted PKCS#8 PEM you downloaded from the portal.
key_passphraseyesDecrypts that PEM in memory at startup.
account_private_key / account_passphraseonly for encrypt_payload webhooksThe company account key, used to unwrap an encrypted webhook envelope.
webhooks / webhook_secretwebhook auth — HMACPer-webhook HMAC secrets keyed by webhook id (matched via the X-Allus-Webhook-Id header). A single-webhook service can use a flat "webhook_secret": "…" instead. The default/recommended webhook auth.
webhook_bearer_tokenwebhook auth — bearerVerify Authorization: Bearer <token> deliveries.
webhook_basicwebhook auth — basic{"username","password"} — verify HTTP Basic deliveries.
webhook_headerwebhook auth — header{"name","value"} — verify a custom-header delivery.
webhook_auth_nonewebhook auth — nonetrue — explicit opt-out; verifyWebhook always passes (use only behind your own gateway).
cache_dirno (default ./allus-cache)Durable local buffer for the changes pump. Must be writable + durable.
formatno (default json)Wire format json or xml. Invisible in the output.
Webhook auth — configure at most one method. Set the one that matches your webhook's auth_type in the portal: HMAC (webhooks/webhook_secret), webhook_bearer_token, webhook_basic, webhook_header, or webhook_auth_none. Configuring two or more fails fast with ConfigError ("configure at most one webhook auth method"). verifyWebhook/handleWebhook then check whichever one you set (constant-time). These alternative auth fields are file-config only (the ALLUS_* env override still applies to the HMAC webhook_secret).

A missing/invalid config, an unreadable PEM, or a wrong passphrase fails fast at construction (a ConfigError / ConfigException).

Install

# Python ≥ 3.11 — a system/Homebrew Python refuses a bare `pip install`
# (PEP 668), so install into a virtualenv:
python3 -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install allus-company-data
// Node ≥ 18 — dual ESM + CommonJS, bundled .d.ts types
npm install @allus-fyi/company-data
// Go ≥ 1.26 — `go get` adds to an existing module:
go get github.com/allus-fyi/company-data-go@latest
// import companydata "github.com/allus-fyi/company-data-go/companydata"

// Starting from scratch instead of an existing module?
//   mkdir my-integration && cd my-integration
//   go mod init my-integration
//   go get github.com/allus-fyi/company-data-go@latest
// .NET 8.0+, namespace Allus.CompanyData — not on NuGet yet; reference it from the GitHub repo:
git clone https://github.com/allus-fyi/company-data-csharp.git
dotnet add reference company-data-csharp/src/Allus.CompanyData/Allus.CompanyData.csproj
// Java 21+, package fyi.allme.allus.companydata — not on Maven Central yet; build from the GitHub repo:
// git clone https://github.com/allus-fyi/company-data-java.git
// cd company-data-java && mvn -q install      // installs fyi.allme.allus:company-data:0.1.0 to your ~/.m2
// then depend on it:
//   Maven:  <dependency><groupId>fyi.allme.allus</groupId><artifactId>company-data</artifactId><version>0.1.0</version></dependency>
//   Gradle: implementation 'fyi.allme.allus:company-data:0.1.0'
// PHP ≥ 8.1 (ext-openssl, ext-json) — PSR-4 autoloaded
composer require allus/company-data

Quickstart — construct the client

Client is the only object you build. Construct it from the config file (env vars override secrets) or entirely from ALLUS_* env:

from allus_company_data import Client

client = Client.from_config("allus.json")
# or: Client.from_env()
import { Client } from '@allus-fyi/company-data';

const client = Client.fromConfig('allus.json');
// or: Client.fromEnv()
client, err := companydata.FromConfig("allus.json")
if err != nil {
    log.Fatal(err)
}
// or: companydata.FromEnv()
using Allus.CompanyData;

using var client = Client.FromConfig("allus.json");
// or: Client.FromEnv() — Client is IDisposable (holds the in-memory key)
import fyi.allme.allus.companydata.Client;

Client client = Client.fromConfig("allus.json");
// or: Client.fromEnv()
use Allus\CompanyData\Client;

$client = Client::fromConfig('allus.json');
// or: Client::fromEnv()

List a connection's values

connections() is a lazy, auto-paged stream of typed Connections; each conn.values[slug] is already decrypted (or a lazy binary handle). It is heavily rate-limited — use it for the initial full sync, not as a poll loop.

B2B fields. A Connection carries customer_type ("person" | "company") and share_code; a change event carries customer_type. RequestField models an audience ("person" | "company" | "both"), but the request-fields endpoint does not send it, so it reads null/empty. All are parse-permissive.

for conn in client.connections():
    print(conn.display_name, conn.person_id)
    for slug, val in conn.values.items():
        print(f"  {slug} = {val.value!r}  (live={val.live}, updated={val.updated_at})")

# one connection by id:
conn = client.connection("019xxxxxxxxxxxxxxxxxxxxxxxxx")
email = conn.values["work_email"].value        # "alice@acme.com"
for await (const conn of client.connections()) {
  console.log(conn.displayName, conn.personId);
  for (const [slug, val] of Object.entries(conn.values)) {
    console.log(`  ${slug} = ${JSON.stringify(val.value)} (live=${val.live}, updated=${val.updatedAt})`);
  }
}

// one connection by id:
const conn = await client.connection('019xxxxxxxxxxxxxxxxxxxxxxxxx');
const email = conn.values['work_email'].value;   // "alice@acme.com"
conns, errs := client.Connections(ctx, 100, 0)   // page size, offset — lazily auto-paged
for conn := range conns {
    fmt.Println(conn.DisplayName, conn.PersonID)
    for slug, val := range conn.Values {
        fmt.Printf("  %s = %v (live=%v, updated=%v)\n", slug, val.Value, val.Live, val.UpdatedAt)
    }
}
if err := <-errs; err != nil {
    log.Fatal(err)
}
// or eagerly: client.ConnectionsList(ctx, 100, 0)

conn, err := client.Connection(ctx, "019xxxxxxxxxxxxxxxxxxxxxxxxx")
email := conn.Values["work_email"].Value.(string)   // "alice@acme.com"
await foreach (var conn in client.ConnectionsAsync())
{
    Console.WriteLine($"{conn.DisplayName} {conn.PersonId}");
    foreach (var (slug, val) in conn.Values)
        Console.WriteLine($"  {slug} = {val.ValueObj} (live={val.Live}, updated={val.UpdatedAt})");
}

var conn = await client.ConnectionAsync("019xxxxxxxxxxxxxxxxxxxxxxxxx");
var email = (string?)conn.Values["work_email"].ValueObj;   // "alice@acme.com"
for (Connection conn : client.connections()) {
    System.out.println(conn.displayName() + " " + conn.personId());
    conn.values().forEach((slug, val) ->
        System.out.printf("  %s = %s (live=%s, updated=%s)%n",
            slug, val.value(), val.live(), val.updatedAt()));
}

Connection conn = client.connection("019xxxxxxxxxxxxxxxxxxxxxxxxx");
Object email = conn.values().get("work_email").value();   // "alice@acme.com"
foreach ($client->connections() as $conn) {
    echo $conn->displayName, ' ', $conn->personId, PHP_EOL;
    foreach ($conn->values as $slug => $val) {
        printf("  %s = %s (live=%s)\n", $slug,
            is_scalar($val->value) ? (string) $val->value : json_encode($val->value),
            $val->live ? 'true' : 'false');
    }
}

$conn  = $client->connection('019xxxxxxxxxxxxxxxxxxxxxxxxx');
$email = $conn->values['work_email']->value;   // "alice@acme.com"

End a connection

A service can end one of its own connections. delete_connection sends DELETE /api/company-data/connections/{id}, returns nothing, and surfaces a refusal as the SDK's API error: 404 company_data.connection_not_found, or 409 company_connections.active_contract while the customer holds an active agreement or subscription on the service. The customer is told by the platform and connection_deleted reaches your pump and webhooks.

# end one of your service's connections (the service side of a disconnect):
client.delete_connection("019xxxxxxxxxxxxxxxxxxxxxxxxx")
// end one of your service's connections (the service side of a disconnect):
await client.deleteConnection('019xxxxxxxxxxxxxxxxxxxxxxxxx');
// end one of your service's connections (the service side of a disconnect):
err := client.DeleteConnection(ctx, "019xxxxxxxxxxxxxxxxxxxxxxxxx")
// end one of your service's connections (the service side of a disconnect):
await client.DeleteConnectionAsync("019xxxxxxxxxxxxxxxxxxxxxxxxx");
// end one of your service's connections (the service side of a disconnect):
client.deleteConnection("019xxxxxxxxxxxxxxxxxxxxxxxxx");
// end one of your service's connections (the service side of a disconnect):
$client->deleteConnection('019xxxxxxxxxxxxxxxxxxxxxxxxx');

Verified values — the verified flag

A person can verify certain fields — email and phone by a code they receive, and identity/address attributes through an identity check; which types is platform admin data, not a fixed set. For a value from a verified field — a live value whose field is verified now, or a one-time value whose field was verified when it was shared — the SDK's value model exposes a plain boolean verified. It is computed after decryption: the platform sends a server-created binding verified_hash = sha256(verified_salt ‖ plaintext), and the SDK recomputes sha256(verified_salt + decrypted_plaintext) and sets verified = true only if it matches. You never see the raw hash.

email = conn.values["work_email"]
if email.verified:            # cryptographically bound to this exact value
    trust_it(email.value)
const email = conn.values["work_email"];
if (email.verified) trustIt(email.value);
email := conn.Values["work_email"]
if email.Verified {                    // cryptographically bound to this exact value
    trustIt(email.Value.(string))
}
var email = conn.Values["work_email"];
if (email.Verified)                    // cryptographically bound to this exact value
    TrustIt((string?)email.ValueObj);
Value email = conn.values().get("work_email");
if (email.verified())                  // cryptographically bound to this exact value
    trustIt(email.value());
$email = $conn->values['work_email'];
if ($email->verified) {                // cryptographically bound to this exact value
    trustIt($email->value);
}

The guarantee: verified: true proves this exact plaintext was verified by the person — a substituted or drifted copy reads verified: false automatically. false is not "wrong", just "not attested" — the value can be perfectly valid and simply never verified. A one-time frozen answer keeps the verification its field held when it was shared, so it stays checkable later; its verified_at is when that field was verified, not a promise about today. Same helper backs the "Sign in with allme" OAuth verified claim.

Company documents

Attach a document (a JSON body or a file) to a connected person, or broadcast one to everyone connected to the service. Encryption is keyed on the target, not on is_private: a per-person document (you pass a connection / person / share code) is always end-to-end encrypted to that recipient's public key automatically — for any is_private — while a broadcast (no target) is plaintext (you can't single-key-encrypt one payload to everyone). is_private is a device-display-only flag (lock-on-reveal vs decrypt-on-load) and requires a per-person target. The SDK fetches the recipient key for you (GET /api/keys/{shareCode}) — no method takes a key/secret argument.

Turn a document into a contract with kind (document / agreement / subscription) plus requires_signature / requires_acceptance. A contract must be per-person (and is therefore always encrypted to the recipient); its status is derived on create (requires_signature → ready_to_sign, else requires_acceptance → offering, else active). The Document model carries requires_signature / requires_acceptance and a signatures[] audit trail (each entry: action, method, content_sha256, plain_sha256, signer_first_name, signer_last_name, signer_name_verified, created_at). The person signs / accepts in-app — you observe it on the changes pump.

The document seal. A signable file document is sealed by the act that completes it: when the last required signature or acceptance is recorded, the platform appends a Signatures page and signs the whole PDF with a platform certificate inside that same act, and every party's copy is replaced with the sealed PDF. When the seal cannot be made, the completing act is refused and nothing is stored, so a completed signable file document always carries its seal (see the document seal; no company-data route triggers a seal). A signable file document's create call takes plain_sha256 (SHA-256 of the raw PDF bytes, lowercase hex) — every SDK computes it from the bytes you pass with a compute_plain_sha256 helper when you don't supply it yourself, so you rarely need to think about it. The Document model carries plain_sha256 and sealed_at on every file document — plain_sha256 is null on a file document with no stored plaintext hash, and such a document cannot be signed; sealed_at is null until the completing act seals it; a JSON contract carries both as null always. document_status_changed's active transition carries the same two fields plus the latest signature's signer members.

# Broadcast a plaintext doc to every connection of the service
client.create_document(name="Terms v3", payload_kind="json",
                       json_value={"url": "https://x/terms.pdf", "version": "3"})

# Per-person doc — AUTO-encrypted to the recipient (any is_private), no key arg
doc = client.create_document(
    name="Your invoice", payload_kind="json", connection_id=conn_id,
    json_value={"amount": "42.00", "due": "2026-07-01"},
    metadata={"can_be_cancelled_in_app": True},
)
# A per-person FILE doc (the bytes are encrypted automatically too):
client.create_document(name="Contract.pdf", payload_kind="file",
                       person_user_id=pid, file_bytes=pdf, file_mime="application/pdf")

# A CONTRACT — must be per-person; status is derived (ready_to_sign here)
contract = client.create_document(
    name="Service agreement", payload_kind="file", connection_id=conn_id,
    kind="agreement", requires_signature=True,
    file_bytes=pdf, file_mime="application/pdf")
# Observe the person's sign/accept/cancel via the changes pump (action field).
# Read the audit trail (each entry: action, method, content_sha256, plain_sha256,
# signer_first_name, signer_last_name, signer_name_verified, created_at):
signed = client.document(contract.id)
print(signed.signatures)
print(signed.sealed_at)  # None until the completing act seals it

client.list_documents(status="active")
client.update_document_status(doc.id, "ended")
client.update_document_metadata(doc.id, name="Invoice #1001")
client.delete_document(doc.id)
// Broadcast a plaintext doc
await client.createDocument({ name: 'Terms v3', payloadKind: 'json',
  jsonValue: { url: 'https://x/terms.pdf', version: '3' } });

// Per-person doc — AUTO-encrypted to the recipient (any isPrivate), no key arg
const doc = await client.createDocument({
  name: 'Your invoice', payloadKind: 'json', connectionId: connId,
  jsonValue: { amount: '42.00', due: '2026-07-01' },
  metadata: { can_be_cancelled_in_app: true },
});
await client.createDocument({ name: 'Contract.pdf', payloadKind: 'file',
  personUserId: pid, fileBytes: pdf, fileMime: 'application/pdf' });

// A CONTRACT — must be per-person; status is derived (ready_to_sign here)
const contract = await client.createDocument({
  name: 'Service agreement', payloadKind: 'file', connectionId: connId,
  kind: 'agreement', requiresSignature: true,
  fileBytes: pdf, fileMime: 'application/pdf' });
// Observe the person's sign/accept/cancel via the changes pump (action field).
// signatures is an untyped list of raw maps, keyed exactly as the wire sends
// them (snake_case): each entry is { action, method, content_sha256,
// plain_sha256, signer_first_name, signer_last_name, signer_name_verified,
// created_at }.
const signed = await client.document(contract.id);
console.log(signed.signatures);
console.log(signed.sealedAt);  // null until the completing act seals it

await client.listDocuments({ status: 'active' });
await client.updateDocumentStatus(doc.id, 'ended');
await client.updateDocumentMetadata(doc.id, { name: 'Invoice #1001' });
await client.deleteDocument(doc.id);
// Broadcast a plaintext doc
client.CreateDocument(ctx, companydata.CreateDocumentOptions{
    Name: "Terms v3", PayloadKind: "json",
    JSONValue: map[string]any{"url": "https://x/terms.pdf", "version": "3"}})

// Per-person doc — AUTO-encrypted to the recipient (any IsPrivate), no key arg
doc, _ := client.CreateDocument(ctx, companydata.CreateDocumentOptions{
    Name: "Your invoice", PayloadKind: "json", ConnectionID: connID,
    JSONValue: map[string]any{"amount": "42.00", "due": "2026-07-01"},
    Metadata:  map[string]any{"can_be_cancelled_in_app": true}})

// A CONTRACT — must be per-person; status is derived (ready_to_sign here)
contract, _ := client.CreateDocument(ctx, companydata.CreateDocumentOptions{
    Name: "Service agreement", PayloadKind: "file", ConnectionID: connID,
    Kind: "agreement", RequiresSignature: true,
    FileBytes: pdf, FileMime: "application/pdf"})
// Observe the person's sign/accept/cancel via the changes pump (Action field).
// Signatures is []map[string]any — the raw wire keys (snake_case), not Go
// field names: each entry is {action, method, content_sha256, plain_sha256,
// signer_first_name, signer_last_name, signer_name_verified, created_at}.
signed, _ := client.Document(ctx, contract.ID)
fmt.Println(signed.Signatures)
fmt.Println(signed.SealedAt) // nil until the completing act seals it

client.ListDocuments(ctx, companydata.ListDocumentsOptions{Status: "active"})
client.UpdateDocumentStatus(ctx, doc.ID, "ended")
client.UpdateDocumentMetadata(ctx, doc.ID, companydata.UpdateDocumentMetadataOptions{Name: "Invoice #1001"})
client.DeleteDocument(ctx, doc.ID)
// Broadcast a plaintext doc
await client.CreateDocumentAsync(new CreateDocumentOptions {
    Name = "Terms v3", PayloadKind = "json",
    JsonValue = new { url = "https://x/terms.pdf", version = "3" } });

// Per-person doc — AUTO-encrypted to the recipient (any IsPrivate), no key arg
var doc = await client.CreateDocumentAsync(new CreateDocumentOptions {
    Name = "Your invoice", PayloadKind = "json", ConnectionId = connId,
    JsonValue = new { amount = "42.00", due = "2026-07-01" },
    Metadata = new { can_be_cancelled_in_app = true } });

// A CONTRACT — must be per-person; status is derived (ready_to_sign here)
var contract = await client.CreateDocumentAsync(new CreateDocumentOptions {
    Name = "Service agreement", PayloadKind = "file", ConnectionId = connId,
    Kind = "agreement", RequiresSignature = true,
    FileBytes = pdf, FileMime = "application/pdf" });
// Observe the person's sign/accept/cancel via the changes pump (Action field).
// Signatures is an untyped list of raw maps, keyed exactly as the wire sends
// them (snake_case): each entry is { action, method, content_sha256,
// plain_sha256, signer_first_name, signer_last_name, signer_name_verified,
// created_at }.
var signed = await client.DocumentAsync(contract.Id);
Console.WriteLine(signed.Signatures);
Console.WriteLine(signed.SealedAt); // null until the completing act seals it

await client.ListDocumentsAsync(status: "active");
await client.UpdateDocumentStatusAsync(doc.Id, "ended");
await client.UpdateDocumentMetadataAsync(doc.Id, name: "Invoice #1001");
await client.DeleteDocumentAsync(doc.Id);
// Broadcast a plaintext doc
client.createDocument(CreateDocumentRequest.builder()
    .name("Terms v3").payloadKind("json")
    .jsonValue(Map.of("url", "https://x/terms.pdf", "version", "3")).build());

// Per-person doc — AUTO-encrypted to the recipient (any isPrivate), no key arg
Document doc = client.createDocument(CreateDocumentRequest.builder()
    .name("Your invoice").payloadKind("json").connectionId(connId)
    .jsonValue(Map.of("amount", "42.00", "due", "2026-07-01"))
    .metadata(Map.of("can_be_cancelled_in_app", true)).build());

// A CONTRACT — must be per-person; status is derived (ready_to_sign here)
Document contract = client.createDocument(CreateDocumentRequest.builder()
    .name("Service agreement").payloadKind("file").connectionId(connId)
    .kind("agreement").requiresSignature(true)
    .fileBytes(pdf).fileMime("application/pdf").build());
// Observe the person's sign/accept/cancel via the changes pump (action field).
// signatures() is an untyped list of raw maps, keyed exactly as the wire sends
// them (snake_case): each entry is { action, method, content_sha256,
// plain_sha256, signer_first_name, signer_last_name, signer_name_verified,
// created_at }.
Document signed = client.document(contract.id());
System.out.println(signed.signatures());
System.out.println(signed.sealedAt());  // null until the completing act seals it

client.listDocuments("active");
client.updateDocumentStatus(doc.id(), "ended");
client.updateDocumentMetadata(doc.id(), Map.of("name", "Invoice #1001"));
client.deleteDocument(doc.id());
// Broadcast a plaintext doc
$client->createDocument(['name' => 'Terms v3', 'payload_kind' => 'json',
    'json_value' => ['url' => 'https://x/terms.pdf', 'version' => '3']]);

// Per-person doc — AUTO-encrypted to the recipient (any is_private), no key arg
$doc = $client->createDocument([
    'name' => 'Your invoice', 'payload_kind' => 'json', 'connection_id' => $connId,
    'json_value' => ['amount' => '42.00', 'due' => '2026-07-01'],
    'metadata' => ['can_be_cancelled_in_app' => true],
]);

// A CONTRACT — must be per-person; status is derived (ready_to_sign here)
$contract = $client->createDocument([
    'name' => 'Service agreement', 'payload_kind' => 'file', 'connection_id' => $connId,
    'kind' => 'agreement', 'requires_signature' => true,
    'file_bytes' => $pdf, 'file_mime' => 'application/pdf',
]);
// Observe the person's sign/accept/cancel via the changes pump (action field).
// signatures is an untyped list of raw arrays, keyed exactly as the wire sends
// them (snake_case): each entry is [action, method, content_sha256,
// plain_sha256, signer_first_name, signer_last_name, signer_name_verified,
// created_at].
$signed = $client->document($contract->id);
print_r($signed->signatures);
var_dump($signed->sealedAt);  // null until the completing act seals it

$client->listDocuments(['status' => 'active']);
$client->updateDocumentStatus($doc->id, 'ended');
$client->updateDocumentMetadata($doc->id, ['name' => 'Invoice #1001']);
$client->deleteDocument($doc->id);
When a person signs, accepts or cancels a document/contract in-app — or a contract-flow run's signing line opens the next step's copy — you receive a document_status_changed event on the same changes pump / webhook rail, slug-free, carrying document_id, status and action plus the signing members listed in the event table. The typed Change exposes document_id + status + action (signed | accepted | cancelled, or null for a move nobody signed; no slug/value); handle it alongside the other events (e.g. if change.event == "document_status_changed": apply(change.document_id, change.status, change.action)).

Service-initiated connect requests

Invite a person to connect with your service by their share code (the reverse of the person typing your handle). One thin call wrapping POST /api/company-data/connect-requests — no crypto, no keys (the request carries no values; the person provides data only when they accept). It returns the request_id; the outcome arrives on the changes pump / webhook as connection_request_accepted or connection_request_rejected.

request_id = client.send_connect_request("ABC123")
# Outcome later on the pump:
#   change.event == "connection_request_accepted" / "connection_request_rejected"
#   change.request_id == request_id
const requestId = await client.sendConnectRequest('ABC123');
// Outcome later on the pump:
//   change.event === 'connection_request_accepted' | 'connection_request_rejected'
//   change.requestId === requestId
requestID, err := client.SendConnectRequest(ctx, "ABC123")
// Outcome later on the pump:
//   c.Event == "connection_request_accepted" / "connection_request_rejected"
//   c.RequestID == requestID
string requestId = await client.SendConnectRequestAsync("ABC123");
// Outcome later on the pump:
//   change.Event == "connection_request_accepted" / "connection_request_rejected"
//   change.RequestId == requestId
String requestId = client.sendConnectRequest("ABC123");
// Outcome later on the pump:
//   change.event().equals("connection_request_accepted") / "connection_request_rejected"
//   change.requestId().equals(requestId)
$requestId = $client->sendConnectRequest('ABC123');
// Outcome later on the pump:
//   $change->event === 'connection_request_accepted' / 'connection_request_rejected'
//   $change->requestId === $requestId
The two outcome events ride the same pump / webhook rail as every other change — handle them in your process_changes handler beside connection_created etc. On accept, a connection_created event also fires (the connection itself); the typed Change exposes request_id on these two events (no slug, no value).

Messaging

Hold a conversation with a connected person — the same messaging surface the person already uses, with your service as the counterpart. Three calls wrap the messaging endpoints: a 1-on-1 send, a broadcast, and the read acknowledgement.

def handle(change):
    if change.event != "message_received":
        return
    print(change.person_id, change.message_body)   # already decrypted

    client.send_message(change.connection_id, "Thanks — we're on it.",
                        person_public_key=change.person_public_key)
    client.mark_messages_read(change.connection_id, change.message_id)

client.process_changes(handle)
client.broadcast_message("We're closed on Friday.")
await client.processChanges(async (change) => {
  if (change.event !== 'message_received') return;
  console.log(change.personId, change.messageBody);   // already decrypted

  await client.sendMessage(change.connectionId, "Thanks — we're on it.",
    { personPublicKey: change.personPublicKey });
  await client.markMessagesRead(change.connectionId, change.messageId);
});
await client.broadcastMessage("We're closed on Friday.");
err := client.ProcessChanges(func(c companydata.Change) error {
    if c.Event != "message_received" {
        return nil
    }
    log.Println(c.PersonID, c.MessageBody) // already decrypted

    if _, err := client.SendMessage(ctx, c.ConnectionID, "Thanks — we're on it.",
        companydata.SendMessageOptions{PersonPublicKey: c.PersonPublicKey}); err != nil {
        return err
    }
    return client.MarkMessagesRead(ctx, c.ConnectionID, c.MessageID)
}, companydata.PumpOptions{})
_, err = client.BroadcastMessage(ctx, "We're closed on Friday.")
await client.ProcessChangesAsync(async change => {
    if (change.Event != "message_received") return;
    Console.WriteLine($"{change.PersonId}: {change.MessageBody}");   // already decrypted

    await client.SendMessageAsync(change.ConnectionId!, "Thanks — we're on it.",
        personPublicKey: change.PersonPublicKey);
    await client.MarkMessagesReadAsync(change.ConnectionId!, change.MessageId!);
});
await client.BroadcastMessageAsync("We're closed on Friday.");
client.processChanges(change -> {
    if (!"message_received".equals(change.event())) return;
    log(change.personId(), change.messageBody());   // already decrypted

    client.sendMessage(change.connectionId(), "Thanks — we're on it.",
                       change.personPublicKey(), null);
    client.markMessagesRead(change.connectionId(), change.messageId());
});
client.broadcastMessage("We're closed on Friday.");
$client->processChanges(function ($change) use ($client): void {
    if ($change->event !== 'message_received') {
        return;
    }
    echo $change->personId, ': ', $change->messageBody, "\n";   // already decrypted

    $client->sendMessage($change->connectionId, "Thanks — we're on it.",
        personPublicKey: $change->personPublicKey);
    $client->markMessagesRead($change->connectionId, $change->messageId);
});
$client->broadcastMessage("We're closed on Friday.");
Acknowledging is not optional, and replying is not acknowledging. A message you never acknowledge stays unread forever: your unread count grows without bound, a portal operator keeps seeing it, and the person never gets a read receipt. The acknowledgement is bounded by the boundary message you name, so a message that arrived while you were working is not swept unhandled, and a repeat is a no-op. The boundary must be a message the person sent on that connection — anything else is refused company_data.ack_boundary_invalid (400).
Refusals surface as the SDK's ApiError equivalent carrying the platform error_key — the full table is on the Service API reference. Nothing here enumerates keys in code, so a new one reaches you without an SDK release.

The customer role (b2b)

The SDKs also ship a customer client — the mirror image of the service client, for a company that connects to another company's service (a b2b link). Where the service client reads the people connected to your service, the customer client manages your own company↔company connections. It authenticates with your account-level acct_* credentials (customer_client_id / customer_client_secret) and decrypts received documents / flow copies with your account private key (config key account_private_key).

Load it with the customer constructor (CustomerClient.from_config() / fromCustomerFile / ConfigFromCustomerFile — one per language) instead of the service one. Config-only keys still apply: no method ever takes a key.

What it does:

No sign / accept. Signing or accepting a contract is a deliberate human step-up that stays in the portal; a machine acct_* token is rejected by the API for those routes, so the customer client deliberately exposes no sign/accept method. The read fields Connection.customer_type, Change.customer_type and RequestField.audience are present on the service client too (they parse permissively).

The changes pump

The steady-state loop. process_changes drains the drain-on-fetch feed through your handler one update at a time, durably buffering each batch before delivery and acking per item — so a mid-batch crash replays exactly what wasn't acked. It returns when the feed is empty; you schedule re-runs (cron / timer). Delivery is at-least-once, so dedup on change.id.

def handle(change):
    if already_processed(change.id):   # idempotency — dedup on the stable id
        return
    if change.event == "field_updated":
        store(change.person_id, change.slug, change.value)
    elif change.event in ("connection_deleted", "field_deleted"):
        remove(change.person_id, change.slug)
    mark_processed(change.id)

client.process_changes(handle)         # returns when the feed is empty
await client.processChanges((change) => {
  if (alreadyProcessed(change.id)) return;       // dedup on change.id
  if (change.event === 'field_updated') {
    store(change.personId, change.slug, change.value);
  } else if (change.event === 'connection_deleted' || change.event === 'field_deleted') {
    remove(change.personId, change.slug);
  }
  markProcessed(change.id);
});
err = client.ProcessChanges(func(c companydata.Change) error {
    if alreadyProcessed(c.ID) {                 // dedup on c.ID
        return nil
    }
    switch c.Event {
    case "field_updated":
        store(c.PersonID, c.Slug, c.Value)
    case "connection_deleted", "field_deleted":
        remove(c.PersonID, c.Slug)
    }
    markProcessed(c.ID)
    return nil // returning nil ACKS; an error RETRIES → then dead-letters
}, companydata.PumpOptions{})
await client.ProcessChangesAsync(async change =>
{
    if (AlreadyProcessed(change.Id)) return;     // dedup on change.Id
    if (change.Event == "field_updated")
        Store(change.PersonId, change.Slug, change.ValueObj);
    else if (change.Event is "connection_deleted" or "field_deleted")
        Remove(change.PersonId, change.Slug);
    MarkProcessed(change.Id);
    await Task.CompletedTask;
});
client.processChanges(change -> {
    if (alreadyProcessed(change.id())) return;   // dedup on change.id()
    switch (change.event()) {
        case "field_updated" -> store(change.personId(), change.slug(), change.value());
        case "connection_deleted", "field_deleted" -> remove(change.personId(), change.slug());
        default -> note(change.event(), change.personId());
    }
    markProcessed(change.id());
});
use Allus\CompanyData\Model\Change;

$client->processChanges(function (Change $change): void {
    if (alreadyProcessed($change->id)) return;   // dedup on $change->id
    if ($change->event === 'field_updated') {
        store($change->personId, $change->slug, $change->value);
    } elseif (in_array($change->event, ['connection_deleted', 'field_deleted'], true)) {
        remove($change->personId, $change->slug);
    }
    markProcessed($change->id);
});                                              // returns when the feed is empty
A handler that keeps failing lands the event in a local dead-letter store instead of wedging the stream; inspect with dead_letters() and re-drive with retry_dead_letters(handler) after fixing the cause. Pump options (per SDK): batch_size (≤ 500, default 100), max_retries (default 3), on_error (deadletter default, or halt), and a backoff function.

The typed value model

You work with these objects and nothing else:

RequestField { slug, label, type, one_time, mandatory, audience?, verified, verified_max_age_days?, plugin? }   // YOUR request config
Connection   { id, person_id, display_name, connected_at, values: {slug: Value},
               customer_type?, share_code?, values_private }
Value        { value, live, updated_at, verified, verified_at?, verified_expires_at?,
               verified_method?, verified_provider?, verification_id?, unreadable }
Change       { id, event, person_id, share_code?, customer_type?, slug?, value?, live?, at,
               verified, verified_at?, verified_expires_at?, verified_method?, verified_provider?, verification_id?,
               request_id?, document_id?, status?, action?, …,
               connection_id?, message_id?, person_public_key?, message_body? }
LogEntry     { type, message, metadata, at }

A value's shape comes from the type's RESOLVED definition, which the SDK reads off the field-type registry it fetches beside your request-field catalog. That reference section is the one place the types, the tree and the rules are described.

The type's resolved…Decrypted value
the reserved type key plugin (checked first)a PluginValue
storage lane photo / documenta lazy binary handle
primitive compositeobject / map (parsed JSON)
primitive datea date (falls back to the raw string if unparseable)
primitive multilista list of the chosen option strings
anything else, and a type the registry does not carrystring

Reach the registry itself as client.fieldTypes() (idiomatic per language) for resolve(), accepts(), isBinary(), labelFor() and validate().

Plugins

A company can answer a request row, a flow field or a sign-in claim through a plugin it added in the portal. The SDKs cover all three sides of that.

Plugin values

A request row answered through a plugin has the reserved type key plugin, and its RequestField carries plugin — {pluginName, fieldType, snapshot}, the plugin's frozen description of the field type (null on every other row). The SDK types its value before consulting the field-type registry, as a PluginValue:

PluginValue { plugin, type, blocks: [{key, kind, label, id, value}], outputs: [{key, type, label, value}] }

blocks is in declared order — a search_select pick carries its id and its option label as value, a typed block its typed value — and outputs are typed as the plugin declared them. A plugin answer is what the person's client submitted, sealed to your key but not signed: check an output with the plugin itself when you must rely on it. A FlowRun carries privateSlugs (null when the API did not send it).

What each SDK adds

WhatPythonTypeScriptGoC#JavaPHP
A flow pass (service Client; CustomerClient takes a leading connection id)plugin_passpluginPassPluginPassPluginPassAsyncpluginPasspluginPass
A block's optionsplugin_optionspluginOptionsPluginOptionsPluginOptionsAsyncpluginOptionspluginOptions
The outputs, or PluginPicksInvalidplugin_outputspluginOutputsPluginOutputsPluginOutputsAsyncpluginOutputspluginOutputs
A field's min/max over the live answer mapcheck_flow_valuecheckFlowValueCheckFlowValueCheckFlowValuecheckFlowValuecheckFlowValue
A required input that cannot be sentPluginInputUnavailablePluginInputUnavailable*PluginInputUnavailableErrorPluginInputUnavailableExceptionPluginInputUnavailableExceptionErrors\PluginInputUnavailable
Read a plugin claim's valueparse_plugin_valueparsePluginValueParsePluginValueOAuthClient.ParsePluginValueOAuthClient.parsePluginValueOAuthClient::parsePluginValue
A plugin server: open a requestplugin_open_requestpluginOpenRequestPluginOpenRequestCrypto.PluginOpenRequestCrypto.pluginOpenRequestCrypto::pluginOpenRequest
A plugin server: seal a replyplugin_seal_replypluginSealReplyPluginSealReplyCrypto.PluginSealReplyCrypto.pluginSealReplyCrypto::pluginSealReply
A reply key pair (RSA-2048)generate_reply_key_pairgenerateReplyKeyPairGenerateReplyKeyCrypto.GenerateReplyKeyCrypto.generateReplyKeyCrypto::generateReplyKeyPair
A public key as base64 SPKIexport_public_key_spkiexportPublicKeySpkiExportPublicKeySPKICrypto.ExportPublicKeySpkiCrypto.exportPublicKeySpkiCrypto::exportPublicKeySpki
Expand plugin answers into dotted keysexpand_plugin_answersexpandPluginAnswersExpandPluginAnswersFlowCondition.ExpandPluginAnswersFlowCondition.expandPluginAnswersFlowCondition::expandPluginAnswers

Webhooks

Each SDK exposes config-driven webhook receiver helpers — verify_webhook, parse_webhook, and the one-liner handle_webhook (verify then parse). They take no key or secret arguments (everything comes from config) and operate on the raw request body bytes (never a re-serialized parsed body — the HMAC is over the exact bytes sent). A parsed webhook is the same typed Change as the pull feed — but do not carry the pull feed's id-dedup over to it: on the webhook channel the id is neither reliably stable nor reliably fresh (a live delivery mints it for that POST; a backlog replay carries the durable row's id, repeated on every re-attempt of that row), so an id check catches some duplicates and misses others. Deliveries are effectively unique; the rare replay is keyed on content, not on the id. See the webhook delivery guarantee, and consume webhooks or the pull feed, never both.

Every example below returns exactly HTTP 200. That is deliberate: allus counts only a 200 as delivered, so a handler that processes an event successfully and answers 202, 204 or a 3xx has its delivery recorded as failed — backlogged, and the webhook's circuit-breaker tripped. Do not "tidy" these to 204.
# Flask
@app.post("/allus/webhook")
def allus_webhook():
    try:
        change = client.handle_webhook(request.get_data(), dict(request.headers))
    except WebhookError:
        abort(401)              # bad / unknown signature, or unparseable envelope
    apply_change(change)   # NOT dedup-on-id: see the webhook delivery guarantee
    return ("", 200)     # 200 — the ONLY status allus counts as delivered
// Express — capture the RAW body bytes. handleWebhook is async: the handler must be
// async and await it, or the try/catch cannot catch a signature or parse failure.
app.post('/allus/webhook', express.raw({ type: '*/*' }), async (req, res) => {
  let change;
  try {
    change = await client.handleWebhook(req.body /* Buffer */, req.headers);
  } catch (e) {
    if (e instanceof WebhookError) return res.sendStatus(401);
    throw e;
  }
  applyChange(change);   // NOT dedup-on-id: see the webhook delivery guarantee
  res.sendStatus(200);   // 200 — the ONLY status allus counts as delivered
});
func webhook(client *companydata.Client) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        body, _ := io.ReadAll(r.Body)              // the RAW bytes — the HMAC is over them
        change, err := client.HandleWebhook(body, r.Header) // verify + parse
        if err != nil {                            // *WebhookError on a bad signature
            http.Error(w, "invalid webhook", http.StatusUnauthorized)
            return
        }
        apply(change)
        w.WriteHeader(http.StatusOK)               // 200 — the ONLY status allus counts as delivered
    }
}
// ASP.NET minimal API — read the EXACT bytes
app.MapPost("/allus/webhook", async (HttpRequest req, Client client) =>
{
    using var ms = new MemoryStream();
    await req.Body.CopyToAsync(ms);
    var headers = req.Headers.ToDictionary(h => h.Key, h => (string)h.Value!);
    try
    {
        var change = client.HandleWebhook(ms.ToArray(), headers); // verify + parse + decrypt
        Apply(change);
        return Results.Ok();   // 200 — the ONLY status allus counts as delivered
    }
    catch (WebhookException) { return Results.Unauthorized(); }
});
// Servlet
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    byte[] body = req.getInputStream().readAllBytes();   // the RAW bytes
    Map<String, String> headers = new HashMap<>();
    var names = req.getHeaderNames();
    while (names.hasMoreElements()) { String n = names.nextElement(); headers.put(n, req.getHeader(n)); }
    try {
        Change change = client.handleWebhook(body, headers);  // verify + parse
        apply(change);
        resp.setStatus(200);                                  // 200 — the ONLY status allus counts as delivered
    } catch (WebhookException e) {
        resp.setStatus(401);                                  // bad / unknown signature
    }
}
$rawBody = file_get_contents('php://input');                  // the RAW bytes
$headers = function_exists('getallheaders') ? getallheaders() : [];
try {
    $change = $client->handleWebhook($rawBody, $headers);
} catch (WebhookError) {
    http_response_code(401);   // bad / unknown signature, or unparseable envelope
    exit;
}
applyChange($change);
http_response_code(200);   // 200 — the ONLY status allus counts as delivered

The full webhook story — configuring them in the portal, the X-Allus-Signature HMAC, and the encrypt_payload account-key envelope — is on the Webhooks page.

Key rotation — key_rotated and the public-key cache

Every SDK caches the RSA public keys it fetches. A person's keypair is set once and never replaced, so a cached person key stays valid; the key_rotated event is reserved and is not emitted. The SDKs still handle it.

On the pump, this is automatic — the SDK drops the cached key as the change passes through, before your handler sees it. Over a webhook it is not: the signature verifier is static and has no client instance, so it cannot reach the cache. Call the invalidator yourself — and note that the two clients key their caches differently: the service Client by share_code, the CustomerClient by the person's user id. Passing a share code to the customer client removes nothing and leaves you encrypting to the old key. Both identifiers ride every change, so use whichever your client needs:

if change.event == "key_rotated":
    # service Client — cache is keyed by SHARE CODE
    client.invalidate_public_key(change.share_code)
    # CustomerClient — cache is keyed by PERSON USER ID
    customer.invalidate_public_key(change.person_id)
if (change.event === 'key_rotated') {
  client.invalidatePublicKey(change.shareCode);      // service Client — keyed by SHARE CODE
  customer.invalidatePublicKey(change.personId);     // CustomerClient — keyed by PERSON USER ID
}
if c.Event == "key_rotated" {
    client.InvalidatePublicKey(c.ShareCode)          // service Client — keyed by SHARE CODE
    customer.InvalidatePublicKey(c.PersonID)         // CustomerClient — keyed by PERSON USER ID
}
if (change.Event == "key_rotated") {
    client.InvalidatePublicKey(change.ShareCode);    // service Client — keyed by SHARE CODE
    customer.InvalidatePublicKey(change.PersonId);   // CustomerClient — keyed by PERSON USER ID
}
if ("key_rotated".equals(change.event())) {
    client.invalidatePublicKey(change.shareCode());  // service Client — keyed by SHARE CODE
    customer.invalidatePublicKey(change.personId()); // CustomerClient — keyed by PERSON USER ID
}
if ($change->event === 'key_rotated') {
    $client->invalidatePublicKey($change->shareCode);   // service Client — keyed by SHARE CODE
    $customer->invalidatePublicKey($change->personId);  // CustomerClient — keyed by PERSON USER ID
}

service_key_rotated — the same thing, the other way round

If you are a customer of another company's service, your client also caches that service's public key — the one you encrypt consent answers and documents to — keyed "companyCode/serviceCode". When the company replaces its service keypair, the service_key_rotated change on your account feed is your only signal; companies receive no pushes. It carries company_share_code and service_share_code (what the cache is keyed on) plus service_public_key_sha256, the fingerprint of the new key.

On the pump this is automatic, exactly as above — the cached service key is dropped as the change passes through. Over a webhook it is not: call the invalidator yourself from your handler, with the two share codes off the event body.

customer.invalidate_service_key(body["company_share_code"], body["service_share_code"])
customer.invalidateServiceKey(body.company_share_code, body.service_share_code);
customer.InvalidateServiceKey(body["company_share_code"], body["service_share_code"])
customer.InvalidateServiceKey(body["company_share_code"], body["service_share_code"]);
customer.invalidateServiceKey(body.get("company_share_code"), body.get("service_share_code"));
$customer->invalidateServiceKey($body['company_share_code'], $body['service_share_code']);

Note the identifiers are share codes, not the ids invalidatePublicKey takes: the two caches are keyed differently, and the wrong call removes nothing. Also eventual, not fail-closed.

Field validation

The value-submit methods type-check every value before encrypting it, so a malformed email / phone / URL / date, an out-of-shape structured object (address / credit-card / bank), or a bad sub-field (e.g. a non-numeric CVC) is caught client-side rather than encrypted into an unusable answer. The rules are identical across all six SDKs (and the allme apps), pinned by a shared parity vector.

The rules are the served registry's, not a table in the SDK: the type's resolved PRIMITIVE, then its named CHECK, then every inherited regex root-first, then its sub-field entries — the same canonical order every allme client applies. See the two vocabularies.

Errors

Same taxonomy and names across all six SDKs:

ErrorWhen
ConfigErrorMissing/invalid config, unreadable key file, or wrong passphrase — at construction (fail fast).
ValidationErrorA submitted value fails its field-type check (see Field validation), or a flow field's minimum or maximum (then it carries bound and boundValue) — raised before encryption. ValidationException in C#/Java.
PluginInputUnavailableA required plugin input is unwired, unanswered, another party's private value, or not convertible to its declared type — carries input, source and reason. PluginInputUnavailableException in C#/Java, *PluginInputUnavailableError in Go.
AuthErrorToken fetch/refresh failed, or a 401 survives the one automatic refresh-and-retry.
ApiError(status, error_key, message)Any non-2xx from the API.
DecryptErrorA ciphertext wrapper is malformed, the key is wrong, or the GCM tag mismatches. It surfaces when a binary value's bytes are read, on a change event and from flow-run routing and generation; connections() / connection(id) never raise it for a value — they mark it unreadable — and flowRunAnswers lists such an answer under unreadable.
WebhookErrorSignature verification failed, or an envelope couldn't be unwrapped/parsed.
RateLimitError(retry_after)A 429 from a rate-limited endpoint (subclass of ApiError); carries retry_after seconds.

(Naming follows each language's conventions — e.g. ConfigException/WebhookException in C# and Java.)

A 503 db.writes_paused means saving is paused — retry it. While the platform cannot complete a save in every region, any call that is not a GET, the change-feed drains (processChanges/drainBatch on both the service and the customer client) and the OAuth client's pollResult can raise ApiError with status 503 and error_key db.writes_paused, sent with Retry-After: 30. Nothing was written — a drain drained nothing and your local buffer is untouched — so wait 30 seconds and repeat the same call. The SDKs do not retry it for you, and ApiError does not carry the Retry-After header. Reads and token requests keep working. See API → errors.
A 503 platform.out_of_order means allme is temporarily out of order — retry it. While the region serving a call is being rebuilt, every call — reads, the change-feed drains (processChanges/drainBatch on both the service and the customer client) and the OAuth client's calls included — can raise ApiError with status 503 and error_key platform.out_of_order, sent with Retry-After: 300. The request was not processed — a drain drained nothing and your local buffer is untouched — so wait 300 seconds and repeat the same call. The SDKs do not retry it for you, and ApiError does not carry the Retry-After header. The client_credentials token request the SDKs make keeps working, so the 503 arrives on the call itself; every other grant at /oauth2/token, the OAuth client's code exchange included, answers it. See API → errors.

Manuals & packages

Each SDK ships its own full manual (README + a docs/ reference for config, model, pump, webhooks, and errors). The summary here is the cross-language view; reach for the language manual for the idiomatic details.

SDKRequiresInstall
PythonPython ≥ 3.11pip install allus-company-data (inside a virtualenv — see Getting started)
TypeScript / NodeNode ≥ 18npm install @allus-fyi/company-data
GoGo ≥ 1.26go get github.com/allus-fyi/company-data-go@latest (inside an existing Go module — see Getting started to start one from scratch)
C# / .NET.NET 8.0From GitHub — clone + dotnet add reference (not on NuGet yet)
JavaJava 21+From GitHub — clone + mvn install → fyi.allme.allus:company-data:0.1.0 (not on Maven Central yet)
PHPPHP ≥ 8.1composer require allus/company-data
Python, TypeScript, Go and PHP install from their package registries. C# and Java install from their GitHub repositories until their packages are published; the coordinates above are the ones those packages will carry.

Next