README
¶
allus company-data (Go)
The Go SDK for the allus company-data API. Point it at a JSON config file and it hands back typed, plaintext, your-slug-keyed conclusions: for each connected person, a map of your request-field slug → plaintext value (plus whether the value is live and when it last changed).
The SDK hides everything else — the OAuth token, the field catalog, the id plumbing, the hybrid decryption, binary fetching, the changes-queue mechanics, JSON-vs-XML. The platform is zero-knowledge: the API only ever holds ciphertext, so all decryption happens inside the SDK with your service private key. The person's own field choices are never exposed — you only ever see the request slots you configured.
This SDK is one of six language ports (Python · Go · TypeScript · C# · Java · PHP) that share an identical API surface. This manual is the Go view of it.
Contents: TL;DR · Quickstart · Every call · The typed value model · Company documents · The changes pump · Webhooks · Rate limits · Errors · How it's wired
Deeper reference pages live in docs/:
config · model · pump ·
webhooks · errors.
TL;DR — fetch new updates
go get github.com/allus-fyi/company-data-go@latest
Point a config.json at your service keys:
{
"api_url": "https://api.allme.fyi",
"client_id": "svc_xxx",
"client_secret": "xxx",
"service_private_key": "/path/to/service.pem",
"key_passphrase": "xxx",
"cache_dir": "./allus-cache"
}
Drain everything new, handled one update at a time:
client, err := companydata.FromConfig("config.json")
if err != nil {
log.Fatal(err)
}
err = client.ProcessChanges(func(c companydata.Change) error {
// c.Event, c.PersonID, c.ShareCode, c.Slug, c.Value, c.Live, c.At
apply(c)
return nil // returning nil ACKS; an error RETRIES → then dead-letters
}, companydata.PumpOptions{})
ProcessChanges pulls every pending change, decrypts it, and hands them to your
callback ONE BY ONE, acking each only after your code returns. Crash mid-batch?
The next run replays exactly what wasn't acked — nothing is lost, and the API
keeps no backlog of its own. Run it on a schedule (cron / systemd timer); there
is no daemon/follow mode by design. Connections, binary values, and webhooks are
documented below.
Quickstart
Requires Go ≥ 1.26.
go get github.com/allus-fyi/company-data-go@latest
import companydata "github.com/allus-fyi/company-data-go/companydata"
The module path is github.com/allus-fyi/company-data-go; everything is exported
from the single package companydata. No manual file includes — go get +
import just works.
1. Write a config file
A single JSON file holds everything. Any field can be overridden by an ALLUS_*
env var, so secrets needn't live in the file. No SDK function or method ever
takes a key, passphrase, or secret as an argument — they all come from here.
allus.json:
{
"api_url": "https://api.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"
}
| Field | Required | Meaning |
|---|---|---|
api_url |
yes | API base, e.g. https://api.allme.fyi. |
client_id / client_secret |
yes | The registered client_credentials credentials for one service. |
service_private_key |
yes | Path to the OpenSSL-encrypted PKCS#8 PEM you downloaded from the portal. |
key_passphrase |
yes | Decrypts that PEM in memory at startup. |
account_private_key / account_passphrase |
only for encrypt_payload webhooks |
The company account key, used to unwrap an encrypted webhook envelope. |
webhooks / webhook_secret |
webhook auth — HMAC (default) | Per-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 of the map. |
webhook_bearer_token |
webhook auth — bearer | Verify Authorization: Bearer <token> deliveries. |
webhook_basic |
webhook auth — basic | {"username","password"} — verify HTTP Basic deliveries. |
webhook_header |
webhook auth — header | {"name","value"} — verify a custom-header delivery. |
webhook_auth_none |
webhook auth — none | true — explicit opt-out; verifyWebhook always passes (use only behind your own gateway). Configure at most one webhook auth method (two+ → ConfigError). |
cache_dir |
no (default ./allus-cache) |
Durable local buffer for the changes pump. Must be writable + durable. |
format |
no (default json) |
Wire format json or xml. Invisible in the output. |
Env overrides use the ALLUS_ prefix of the field name, e.g.
ALLUS_CLIENT_SECRET, ALLUS_KEY_PASSPHRASE, ALLUS_ACCOUNT_PASSPHRASE,
ALLUS_WEBHOOK_SECRET. A missing/invalid config (or an unreadable PEM / wrong
passphrase) returns a *ConfigError at construction — fail fast
(errors.Is(err, ErrConfig)).
2. First call — list a connection's values
client, err := companydata.FromConfig("allus.json")
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
// Iterate every connected person (lazy, auto-paged).
conns, errc := client.Connections(ctx, 100, 0)
for conn := range conns {
fmt.Println(conn.DisplayName, conn.PersonID)
for slug, val := range conn.Values {
fmt.Printf(" %s = %v (live=%t, updated=%v)\n", slug, val.Value, val.Live, val.UpdatedAt)
}
}
if err := <-errc; err != nil {
log.Fatal(err)
}
Or get all of them eagerly into a slice:
conns, err := client.ConnectionsList(ctx, 100, 0) // initial full sync
Or fetch one connection by id:
conn, err := client.Connection(ctx, "019xxxxxxxxxxxxxxxxxxxxxxxxx")
email := conn.Values["work_email"].Value.(string) // "alice@acme.com"
companydata.FromEnv() builds the same client entirely from ALLUS_* env vars
(no file).
Every call
Client is the only object you construct. Build it from config, then:
companydata.FromConfig(path string, opts ...clientOption) (*Client, error) // from a JSON file (env overrides secrets)
companydata.FromEnv(opts ...clientOption) (*Client, error) // entirely from ALLUS_* env vars
companydata.New(cfg *Config, opts ...clientOption) (*Client, error) // from a prebuilt Config
Options are advanced/optional: WithHTTPClient (inject a custom transport),
WithLogger (a *log.Logger for the pump's drain/ack/retry/dead-letter logging).
| Method | Returns | What it does |
|---|---|---|
RequestFields(ctx) |
[]RequestField, error |
Your request-field definitions (slug → label/type/flags). Fetched once and cached. Never the person's fields. |
Connections(ctx, limit, offset) |
(<-chan Connection, <-chan error) |
A lazy channel of Connection, auto-paging the list endpoint (a short page ends iteration). Read the error channel after the connection channel closes. |
ConnectionsList(ctx, limit, offset) |
[]Connection, error |
Eager convenience — drains the iterator into a slice (initial full sync). |
Connection(ctx, id) |
Connection, error |
One connection by id. |
Logs(ctx, limit, offset) |
[]LogEntry, error |
The service's activity log (ops events only — email/purge/webhook). |
ProcessChanges(handler, opts) |
error |
The crash-safe streaming pump (one Change at a time, durable buffer, retry→dead-letter, until empty then returns). See the changes pump. |
DrainBatch(max) |
[]Change, error |
A raw, unbuffered drain (advanced — you own durability). |
DeadLetters() |
[]DeadLetter, error |
Inspect the local dead-letter store. |
RetryDeadLetters(handler, opts) |
int, error |
Re-drive dead-lettered events; returns how many succeeded. |
VerifyWebhook(rawBody, headers) |
bool |
Verify a webhook's HMAC signature. |
ParseWebhook(rawBody, headers) |
Change, error |
Parse a webhook body → a Change. |
HandleWebhook(rawBody, headers) |
Change, error |
Verify + parse in one call. |
ctx context.Context lets you cancel/timeout the network calls. headers on the
webhook methods may be a map[string]string or an http.Header
(map[string][]string).
Usage intent (enforced by the API). Poll
ProcessChanges/ the changes feed as often as you like — it's a cheap drain-on-fetch queue, generously rate-limited. The connections endpoints are heavily rate-limited: use them for the initial full sync + occasional reconciliation, never as a polling substitute for the changes feed.
The typed value model
Everything you read is one of these. Names are Go-idiomatic; shapes match the other five SDKs.
type RequestField struct { Slug, Label, Type string; OneTime, Mandatory bool; Raw map[string]any }
type Connection struct { ID, PersonID, DisplayName string; ConnectedAt *time.Time; Values map[string]Value; Raw map[string]any }
type Value struct { Value any; Live bool; UpdatedAt *time.Time; Raw map[string]any }
type Change struct { ID, Event, PersonID, ShareCode, Slug string; Value any; Live, HasLive bool; At *time.Time; Raw map[string]any }
type LogEntry struct { Type, Message string; Metadata any; At *time.Time; Raw map[string]any }
-
Keyed by your slug.
conn.Values["work_email"].Value→"x@y.com". The slug is the stable, explicit key you set per request field in the portal — rename the label freely, the slug is the contract. -
Value.Valueis typed (use a type switch / assertion):Field type Go type of Value.Valueemail/phone/url/textstring(phoneis a single E.164-style string:+and digits)country/nationalitystring— an ISO 3166-1 alpha-2 code (e.g."US","NL"); not a display nameaddress/bank/creditcardmap[string]any(parsed JSON object)date/date_of_birthtime.Timephoto/document/legal_document*BinaryHandle(lazy)email := conn.Values["work_email"].Value.(string) addr := conn.Values["billing_address"].Value.(map[string]any) logo := conn.Values["logo"].Value.(*BinaryHandle) bytes, err := logo.Bytes() // fetches the slot file endpoint + decrypts on demand n, err := logo.Save("./logo.png") // atomic write (temp + fsync + rename)country/nationalityvalues are 2-letter ISO codes, and anaddress'scountry/statesub-fields are an ISO alpha-2 code / USPS 2-letter state code respectively.FieldValueValid(type, value)validates these against the bundled country dataset;IsValidCountryCode(code)/DialCodeFor(code)check a code or look up its E.164 dial code. -
Value.Live= the person chose "keep connected" (auto-updates) vs a one-time snapshot.Value.UpdatedAt= when this answer last changed (*time.Time, nil if absent). Both ride on theValue(per-answer), not the definition. -
The person's source field is never present — no source slug, no
field_id, not even viaRaw(the hardened API doesn't return it). -
Rawon any object → the underlying (hardened) API map, for debugging or an edge case the SDK didn't model. It still never contains the person's source field.
Binary fields
A binary answer is a *BinaryHandle: Bytes() GETs the slot-keyed file
endpoint, decrypts the wrapper, parses the envelope ({"full":…} /
{"file":…}), and base64-decodes the primary data-URI payload into the file
bytes. Save(path) writes those bytes atomically. The handle is lazy and caches
the decrypted envelope, so repeated Bytes()/Save() don't re-fetch.
Company documents
A service can also push documents to people — contracts, statements, signed PDFs, structured JSON payloads. Unlike the request-field values (which the person fills in for you), documents flow the other way: you create them and the recipient's app shows them.
The one rule to internalise — encryption is keyed on the target, never on
IsPrivate:
- Per-person (set
ConnectionID,PersonUserID, orShareCode): the value is always end-to-end encrypted to that recipient's public key before it leaves the process — for every per-person document,IsPrivateor not. The SDK resolves the recipient's share code, fetches their public key, and encrypts with it. No method takes a key or secret argument. - Broadcast (no target): the value is sent plaintext. You can't
single-key-encrypt one blob to all of a service's connections, so broadcast
documents are inherently public-to-your-connections. A broadcast therefore
must be non-private —
IsPrivate: truewith no target returns a*ConfigError.
IsPrivate is a device-display-only flag: it tells the recipient's app to
show a lock (tap-to-reveal) vs decrypt-and-show-on-load. It does not decide
whether the value is encrypted (the target does). It only makes sense per-person.
PayloadKind is "json" (a structured object) or "file" (raw bytes — PDF,
image, …).
Create
client, err := companydata.FromConfig("allus.json")
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
// BROADCAST, plaintext JSON — every connection sees it.
notice, err := client.CreateDocument(ctx, companydata.CreateDocumentOptions{
Name: "Q3 service notice",
PayloadKind: "json",
JSONValue: map[string]any{
"headline": "Scheduled maintenance",
"starts": "2026-07-01T02:00:00Z",
},
})
// PER-PERSON, JSON — auto-encrypted to this recipient's public key.
// (IsPrivate optional; it only changes how their device displays it.)
contract, err := client.CreateDocument(ctx, companydata.CreateDocumentOptions{
ConnectionID: "019xxxxxxxxxxxxxxxxxxxxxxxxx", // or PersonUserID / ShareCode
Name: "Service agreement",
PayloadKind: "json",
IsPrivate: true, // recipient's app shows a lock; the bytes are encrypted either way
JSONValue: map[string]any{"plan": "pro", "monthly_eur": 49},
Status: "offering",
})
// PER-PERSON, FILE — the file bytes are encrypted to the recipient before upload.
pdf, _ := os.ReadFile("./agreement.pdf")
signed, err := client.CreateDocument(ctx, companydata.CreateDocumentOptions{
PersonUserID: "019yyyyyyyyyyyyyyyyyyyyyyyyy",
Name: "Signed agreement",
PayloadKind: "file",
FileBytes: pdf,
FileMime: "application/pdf",
})
Setting ConnectionID or PersonUserID makes a document per-person; ShareCode
short-circuits the recipient-share-code lookup if you already have it. The
returned Document carries ID, Status, PayloadKind, IsPrivate,
Metadata, timestamps, and (for per-person JSON) the encrypted Value — call
doc.JSON() to get the plaintext object back (it decrypts a per-person wrapper
with your own key, or returns a broadcast value as-is).
List / fetch / update / delete
// List this service's documents (optional person/status filter + paging).
docs, err := client.ListDocuments(ctx, companydata.ListDocumentsOptions{
PersonUserID: "019yyyy…", // optional
Status: "active", // optional
Limit: 100, Offset: 0,
})
// One document by id.
doc, err := client.Document(ctx, "019zzzz…")
obj, _ := doc.JSON() // plaintext (decrypts a per-person wrapper transparently)
// #491: download a FILE document's bytes (metadata methods don't include them).
// A broadcast document comes back as plaintext bytes directly. A per-person
// document is encrypted to that RECIPIENT (not your service key) and DocumentFile
// returns an *ApiError{ErrorKey: "documents.recipient_encrypted"} instead of a
// doomed decrypt attempt — for a generated flow contract's own company copy, use
// FlowRunDocument below.
pdf, err := client.DocumentFile(ctx, "019zzzz…")
// #491: a generated flow contract's COMPANY copy — encrypted to your service
// key, so (unlike DocumentFile on the same document) this one DOES decrypt.
pdf, err = client.FlowRunDocument(ctx, runID)
// Advance the lifecycle status.
// offering | ready_to_sign | active | active_but_ending | ended
doc, err = client.UpdateDocumentStatus(ctx, doc.ID, "active")
// Update metadata / name / description (any one of the three is required).
doc, err = client.UpdateDocumentMetadata(ctx, doc.ID, companydata.UpdateDocumentMetadataOptions{
Name: "Service agreement (v2)",
Description: "Renewed terms",
Metadata: map[string]any{"ref": "AC-2026-0007"},
})
// Delete the document (and its on-disk file).
err = client.DeleteDocument(ctx, doc.ID)
DocumentFile/FlowRunDocument are the only two calls that return actual file
bytes — Document/ListDocuments are metadata-only, and GenerateFlowDocument
returns just {document_id, status}. Which one to call depends on whose copy
you want: DocumentFile(documentID) for a document you pushed (broadcast comes
back plaintext; per-person is encrypted to the recipient, so it errors with
documents.recipient_encrypted rather than attempting a decrypt that can't
succeed with your key); FlowRunDocument(runID) for the company-party copy of a
document a contract flow generated (that copy is encrypted to YOUR service key
and decrypts transparently, the same way a photo/document *BinaryHandle
does).
React to status changes in the pump
When a recipient acts on a document, the change feed emits a
document_status_changed event carrying DocumentID + Status (no slug, no
value) — handle it alongside the field events:
err := client.ProcessChanges(func(c companydata.Change) error {
switch c.Event {
case "document_status_changed":
// e.g. the person moved a contract offering → ready_to_sign → active
onDocumentStatus(c.PersonID, c.DocumentID, c.Status)
case "field_updated":
upsert(c.PersonID, c.Slug, c.Value)
}
return nil
}, companydata.PumpOptions{})
The same event arrives over webhooks with the identical Change
shape.
Contract flows (company side)
A flow is a company-defined, multi-step info-gathering run bound to a connection (e.g. onboarding, a signable agreement). The company is one of the run's bound parties.
| Method | Returns | What it does |
|---|---|---|
TriggerFlowRun(ctx, flowID, connectionID, bindings) |
FlowRun, error |
Starts a run, pinning the flow's latest published version. bindings = {party_key: user_id}. |
FlowRuns(ctx, status) / FlowRunsAll(ctx) |
[]FlowRun, error |
Lists this service's runs (status == "" defaults to the actionable awaiting_company queue; FlowRunsAll is unfiltered). |
FlowRun(ctx, runID) |
FlowRun, error |
Fetches one run by id. |
SubmitFlowAnswers(ctx, run, fill, partyPubKeys) |
FlowRun, error |
Fills the company's current node, encrypts one answer copy per bound party, and advances the run. |
GenerateFlowDocument(ctx, run) |
any, error |
Runs a document-mode leaf: one-time-key-encrypts the answers and kicks off contract generation. Returns {document_id, status} (no bytes — see below). |
ProcessFlowRun(ctx, runID, fillNode, partyPubKeys) |
FlowRun, error |
The high-level company turn: load → (if it's our turn) fill + advance + generate, chained. |
FlowRunAnswers(run) |
map[string]any, error |
(#491) A completed run's DECRYPTED answers as {slug: plaintext} — the public accessor for reading a finished run's answers (decrypts the company's own service-key answer copies of an already-fetched FlowRun). |
Identity(ctx) |
Identity, error |
(#491) This client's OWN identity — {CompanyUserID, ServiceID} from GET /api/company-data/whoami. The company party of a TriggerFlowRun/SubmitFlowAnswers binding must bind to CompanyUserID (the person party's user_id comes from the connection), so without this the company-side binding was otherwise unconstructible through the SDK. |
run, err := client.TriggerFlowRun(ctx, flowID, connectionID, map[string]string{
"company": mustIdentity.CompanyUserID, // from client.Identity(ctx)
"person": personUserID, // from the Connection
})
// ... after the run reaches a company-turn state (see ProcessFlowRun/SubmitFlowAnswers) ...
run, err = client.FlowRun(ctx, run.ID)
answers, err := client.FlowRunAnswers(run) // {slug: plaintext}, e.g. answers["monthly_eur"]
Once a document-mode leaf has generated the contract, download its bytes with
FlowRunDocument(runID) — see Company documents above.
The changes pump
The changes feed is a server-side drain-on-fetch queue:
GET /api/company-data/changes?limit=N returns up to N events and deletes
exactly those rows in the same transaction — no offset/cursor/page. Because the
API keeps no copy after a fetch, the SDK must not lose a drained batch if your
process crashes, and must never materialize a huge backlog. So consumption goes
through a pump, not a list.
err := client.ProcessChanges(func(c companydata.Change) error {
switch c.Event {
case "field_updated":
// c.Slug + c.Value (decrypted at delivery; a *BinaryHandle for binaries)
upsert(c.PersonID, c.Slug, c.Value)
case "connection_created", "connection_deleted":
// no slot/value
case "consent_accepted", "consent_declined":
// c.Slug, no value
}
return nil // returning nil ACKS; returning an error RETRIES → then dead-letters
}, companydata.PumpOptions{}) // defaults: BatchSize 100, MaxRetries 3, OnError deadletter
Per cycle:
- Replay first — deliver any un-acked events already in the local buffer (from a previous crashed run), oldest-first.
- Drain — when the buffer is empty, fetch one batch (≤
BatchSize, ≤500) and persist it to the durable buffer (fsync) BEFORE handing anything out. - Deliver one-by-one — decrypt each event at delivery (never on disk), call your handler.
- Ack / retry / dead-letter — on
nilremove the event; on error retry with backoff up toMaxRetries, then (default) move it to the dead-letter store and continue (one poison event never wedges the stream), or — withOnError: companydata.OnErrorHalt— stop and return the error. - Repeat until a drain returns empty and the buffer is drained → return.
There is no follow/daemon mode — ProcessChanges returns when the feed is
empty; you schedule re-runs (a ticker, cron, a worker — whatever fits).
Guarantees:
- Crash-safe — a batch is durably buffered before any delivery; a crash mid-batch replays the un-acked events on the next run. Nothing the API deleted is lost.
- Bounded memory — only one ≤500-event batch is in flight; a 1M backlog streams through in chunks.
- At-least-once + idempotent — the ack can't be atomic with your side-effects,
so your handler must be idempotent. Each
Changecarries a stableID(captured before the server delete) so you can dedupe. - Ciphertext at rest — the local buffer stores the ciphertext event; values are decrypted only at delivery. No plaintext PII is written to disk.
PumpOptions:
type PumpOptions struct {
BatchSize int // ≤500, default 100
MaxRetries int // default 3
OnError OnError // OnErrorDeadLetter (default) | OnErrorHalt
Backoff func(attempt int) time.Duration // default exponential, capped at 30s
}
Dead-letter
Events that exhaust MaxRetries — or that can never decrypt (corrupt/rotated
key, dead-lettered immediately without burning retries) — land in a
dead-letter store under cache_dir, with the error + attempt count. They are
never silently dropped, and never re-fetched from the API (the API already
deleted them — the local buffer is their only home).
dls, _ := client.DeadLetters() // inspect (ciphertext + error + attempts)
n, _ := client.RetryDeadLetters(handler, companydata.PumpOptions{}) // re-drive; n = how many succeeded
A still-failing re-drive is updated in place in the dead-letter dir (never routed back through the pending dir), and the recorded attempt count is monotonic.
Key rotation — key_rotated and the public-key cache
Every client caches the RSA public keys it fetches: a person's key is immutable — until they
rotate it. A person learns of a rotation from a silent push; your service gets no pushes, so the
key_rotated change is your only signal. Without it a long-running worker keeps encrypting to
the rotated-away key for its whole lifetime, and the person can never read those values.
On the pump this is automatic — the cached key is dropped 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 — noting that the two
clients key their caches differently: the service client by share_code, the customer client 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, alongside public_key_sha256 — the
fingerprint of the person's new key.
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
_ = c.PublicKeySHA256 // fingerprint of the NEW key, if you want to verify the refetch
}
This is eventual, not fail-closed — nothing rejects a document encrypted to a stale key, so a window remains between the rotation and your next drain. Drain often if that window matters.
service_key_rotated — the same thing, the other way round
The customer client also caches the service's public key, the one you encrypt your consent
answers and documents to, keyed "companyCode/serviceCode". When that company replaces its
service keypair, the service_key_rotated change on your account feed is your only signal — you
receive no pushes. Same shape, same guarantees, same automatic handling on the pump:
if c.Event == "service_key_rotated" {
// Automatic on the pump. Over a webhook, from the raw event body:
customer.InvalidateServiceKey(body["company_share_code"], body["service_share_code"])
// body["service_public_key_sha256"] = fingerprint of the service's NEW key
}
Also eventual, not fail-closed. Note the identifiers are share codes, not the ids used by
InvalidatePublicKey — the two caches are keyed differently and the wrong call removes nothing.
Webhooks
The lower-latency push alternative to polling the feed. All secrets/keys come from config — these helpers take no key or secret arguments.
func webhook(client *companydata.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) // the RAW bytes — the HMAC is over them
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
change, err := client.HandleWebhook(body, r.Header) // verify + parse
if err != nil {
// *WebhookError on a bad/unknown signature or an unwrappable envelope
http.Error(w, "invalid webhook", http.StatusBadRequest)
return
}
process(change) // same Change shape + types as the feed
w.WriteHeader(http.StatusOK)
}
}
VerifyWebhook(rawBody, headers) bool— readsX-Allus-Webhook-Id, looks up that webhook's HMAC secret in config, recomputesHMAC-SHA256(rawBody, secret)and constant-time-compares it toX-Allus-Signature(hex). The HMAC is always over the raw bytes, never the parsed tree.ParseWebhook(rawBody, headers) (Change, error)— parses the body (JSON or XML) → aChange. If the body is anencrypt_payloadenvelope (encrypted to your account key), the SDK unwraps it with the configuredaccount_private_keyfirst, then decrypts the inner field value with the service key.HandleWebhook(rawBody, headers) (Change, error)— verify + parse in one call; returns a*WebhookErroron a bad/unknown signature.
The 6 event types + fields are identical to the feed (slug-keyed; no person source field).
Delivery contract — effectively unique, rarely replayed
Each queued event is POSTed once, and only HTTP 200 counts as delivered — a
202, a 204, a 3xx redirect and every 4xx/5xx are all treated as a failure. On anything
other than 200 (or a timeout or connection error) the event is not retried in place:
it and the rest of the webhook's queue move to a durable server-side backlog and the
webhook is marked bad. The backlog is delivered later, either automatically when
the webhook next probes healthy, or when you drain it yourself with
GET /api/company-data/changes?webhook_id=… (delete-on-read).
So deliveries are effectively unique — with one rare exception. If your endpoint
processed an event but the platform never saw your 200 (your response timed out, or
you crashed after committing but before responding), the event is treated as failed
and replayed on recovery, so you receive it again. Nothing caps that at two: a
failed probe leaves its backlog row in place, so every later recovery attempt whose
200 is likewise lost replays the same event once more. Inside that window the
contract is at-least-once — plan for one or more repeats, not for exactly one.
Do not use
change.IDas an idempotency key here. On the webhook path the id is neither reliably stable nor reliably fresh, and a receiver cannot tell which one it is holding. A live delivery is built with no change row behind it, so its id is minted for that single POST — the later replay of the same event is rebuilt from a durable backlog row and therefore carries a different id. But a replayed delivery carries that row's id, and the row stays in place until it is delivered successfully, so a re-attempted replay arrives with the same id — which changes again if the event is re-backlogged after a further failure. An id check therefore misses the duplicate you are most likely to see and matches only a rarer one; it is not a contract. If you need strict idempotency, key on the content — event + person + slug/document + payload — never on the id.
Webhooks and the pull feed are alternative integrations — consume one, never both.
The id-dedup guidance in the changes-pump section above applies to the pump only, where
change.ID is the real server change-row id.
Rate limits
The SDK respects 429 + Retry-After automatically. The changes feed is meant
to be polled often (generous limit). The connections endpoints are heavily
rate-limited: the connections iterator paces itself and backs off on a surfaced
429, retrying a page a bounded number of times before returning a
*RateLimitError. Use connections for the initial full sync + occasional
reconciliation, not as a poll target — that's what the changes feed is for.
Errors
Idiomatic Go error types matching the §9 taxonomy. Each has a sentinel for
errors.Is; the concrete types extract carried fields with errors.As.
| Error type | Sentinel | When |
|---|---|---|
*ConfigError |
ErrConfig |
Missing/invalid config or key file at construction (fail fast). |
*AuthError |
ErrAuth |
Token fetch/refresh failed (bad client_id/secret, revoked client). |
*ApiError (Status, ErrorKey, Message) |
ErrAPI |
Any non-2xx from the API. |
*DecryptError |
ErrDecrypt |
Wrapper malformed, wrong key, or GCM tag mismatch. |
*WebhookError |
ErrWebhook |
Signature verification failed or an envelope couldn't be unwrapped. |
*RateLimitError (RetryAfter) |
ErrRateLimit (also ErrAPI) |
A 429 from a rate-limited endpoint (embeds *ApiError). |
if errors.Is(err, companydata.ErrRateLimit) {
var rl *companydata.RateLimitError
errors.As(err, &rl)
// back off rl.RetryAfter (a *float64 of seconds, or nil)
}
var apiErr *companydata.ApiError
if errors.As(err, &apiErr) {
log.Printf("API %d %s: %s", apiErr.Status, apiErr.ErrorKey, apiErr.Message)
}
How it's wired
- Auth + transport. An internal
HTTPClientowns theclient_credentialstoken (fetched on first call, cached, auto-refreshed near expiry; one refresh-and-retry on a 401), theAcceptheader performat, the JSON/XML parse, and the §9 error mapping (incl. bounded 429 backoff). The token is scoped server-side to exactly one service. - Decryption. The service private key is loaded once at construction from the configured encrypted PEM + passphrase into an in-memory RSA key; a decrypt closure over it is handed to every model factory and the pump. The key never appears in a method signature (config-only key handling). Algorithm: RSA-OAEP-SHA256 (digest + MGF1) unwrap of the AES key, then AES-256-GCM (12-byte nonce, 16-byte tag appended). Verified byte-for-byte against the shared cross-language test vector.
- Slug catalog.
RequestFieldsis fetched once and cached; its slug→type map types every value. - Binary. A
*BinaryHandle.Bytes()GETs the slot file endpoint, unwraps the API's{"encrypted":true,"value":<wrapper>}envelope, and runs the same service-key decrypt → the file bytes. - Changes feed.
ProcessChangesdelegates to the durable-bufferedPump, injecting a drain closure (GET /changes?limit=, returning the raw ciphertext events) and a decrypt closure that builds a typedChange. - XML. Go's
encoding/xmlis XXE-safe by default — it does not resolve external entities or DTDs — so there is no entity resolver to disable. The HMAC is always computed over the raw bytes, never the parsed tree.
Development
go build ./...
go vet ./...
go test ./...
go test -race ./...
The test suite covers the decryption core (PBES2 PEM load, text decrypt, and binary decrypt → envelope → inner bytes), the crash-safe pump (persist-before-deliver, replay, dead-letter, monotonic attempt counts, never-resurrect), and the webhook helpers (HMAC verify, JSON/XML parse, and the account-key OAEP-SHA1 envelope).
Sign in with allme (OAuth, #195)
oauth, _ := companydata.OAuthClientFromConfig("idw-config.json")
url, _ := oauth.AuthorizeURL("signin", &companydata.AuthorizeURLOptions{State: state, CodeChallenge: ch})
// ...user approves; your redirect receives ?code=...
res, _ := oauth.CompleteSignIn(code, verifier) // res.User, res.Mode, res.Values (plaintext)
Modes: signin | one_time (claim values decrypted for you) | connect |
2fa_enroll (opt a person into 2FA — see below).
2FA by allme (#436, #481)
Ask a connected person to approve a login inside the allme app. On the same service data client (no new
config), via the TwoFactor() sub-client:
client, err := companydata.FromConfig("allus.json")
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
// Raise a challenge. The idempotency key is REQUIRED — a repeat with the same key within the TTL returns
// the SAME challenge and sends no second push. The context is plain text shown on the person's card
// (pass "" for none).
ch, err := client.TwoFactor().Challenge(ctx, "2I6UF3", "login-8f3c1a", "Sign-in from Chrome")
if err != nil {
log.Fatal(err)
}
if ch.MatchingDigits != "" { // number matching is on for this service
showOnLoginPage(ch.MatchingDigits) // the person types these back into the app; the server checks them
}
// Wait for the terminal outcome — polls Result for you (0, 0 → defaults: 600s timeout, 2s interval),
// returns an *ApiError on timeout.
res, err := client.TwoFactor().WaitForResult(ctx, ch.ChallengeID, 0, 0) // or Result(ctx, ch.ChallengeID) to poll once yourself
if err != nil {
log.Fatal(err)
}
if res.Status == "approved" {
grantLogin()
}
- Burn-on-read. The first read of a terminal state (
approved|denied|expired|revoked) delivers it and burns it — a later read isgone. Read it once and persist your own outcome;WaitForResultreturns that first terminal read and never re-reads a consumed challenge. - Webhook variant. The
2fa_challenge_completedchange/webhook carries the same terminalStatus, so a webhook consumer need not poll. Expiry fires no webhook/Change — onlyapproved/denied/revokedreach the feed, so a lapsed challenge is observable only by polling. - Enrollment. Only an enrolled person can be challenged (an un-enrolled
share_codeis404). Enrollment is a one-time consent on theweb.allme.fyi/authsurface via the OAuth helper's2fa_enrollmode — a redirect button (oauth.AuthorizeURL("2fa_enroll", &companydata.AuthorizeURLOptions{State: state})), or server-to-server withResponseMode: "detached"+PollResult(state, 0, 0), which returns{"enrolled": true, "state": ...}once the person confirms. - Errors.
404(unknown / not-enrolled share code). A429is either the plain rate limit (retried with backoff →*RateLimitError) ortwofa.pending_cap(too many challenges already open for this person) — the latter surfaces immediately as*ApiErrorand is never retried, since a retry cannot clear it.
Directories
¶
| Path | Synopsis |
|---|---|
|
DO NOT EDIT — generated from /translations/countries.json
|
DO NOT EDIT — generated from /translations/countries.json |