company-data-go

module
v0.0.15 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 27, 2026 License: MIT

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.Value is typed (use a type switch / assertion):

    Field type Go type of Value.Value
    email / phone / url / text string (phone is a single E.164-style string: + and digits)
    country / nationality string — an ISO 3166-1 alpha-2 code (e.g. "US", "NL"); not a display name
    address / bank / creditcard map[string]any (parsed JSON object)
    date / date_of_birth time.Time
    photo / 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/nationality values are 2-letter ISO codes, and an address's country/state sub-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 the Value (per-answer), not the definition.

  • The person's source field is never present — no source slug, no field_id, not even via Raw (the hardened API doesn't return it).

  • Raw on 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, or ShareCode): the value is always end-to-end encrypted to that recipient's public key before it leaves the process — for every per-person document, IsPrivate or 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: true with 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:

  1. Replay first — deliver any un-acked events already in the local buffer (from a previous crashed run), oldest-first.
  2. Drain — when the buffer is empty, fetch one batch (≤ BatchSize, ≤500) and persist it to the durable buffer (fsync) BEFORE handing anything out.
  3. Deliver one-by-one — decrypt each event at delivery (never on disk), call your handler.
  4. Ack / retry / dead-letter — on nil remove the event; on error retry with backoff up to MaxRetries, then (default) move it to the dead-letter store and continue (one poison event never wedges the stream), or — with OnError: companydata.OnErrorHalt — stop and return the error.
  5. Repeat until a drain returns empty and the buffer is drained → return.

There is no follow/daemon modeProcessChanges 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 Change carries a stable ID (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 — reads X-Allus-Webhook-Id, looks up that webhook's HMAC secret in config, recomputes HMAC-SHA256(rawBody, secret) and constant-time-compares it to X-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) → a Change. If the body is an encrypt_payload envelope (encrypted to your account key), the SDK unwraps it with the configured account_private_key first, then decrypts the inner field value with the service key.
  • HandleWebhook(rawBody, headers) (Change, error) — verify + parse in one call; returns a *WebhookError on 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.ID as 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 HTTPClient owns the client_credentials token (fetched on first call, cached, auto-refreshed near expiry; one refresh-and-retry on a 401), the Accept header per format, 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. RequestFields is 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. ProcessChanges delegates to the durable-buffered Pump, injecting a drain closure (GET /changes?limit=, returning the raw ciphertext events) and a decrypt closure that builds a typed Change.
  • XML. Go's encoding/xml is 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 is gone. Read it once and persist your own outcome; WaitForResult returns that first terminal read and never re-reads a consumed challenge.
  • Webhook variant. The 2fa_challenge_completed change/webhook carries the same terminal Status, so a webhook consumer need not poll. Expiry fires no webhook/Change — only approved/denied/revoked reach the feed, so a lapsed challenge is observable only by polling.
  • Enrollment. Only an enrolled person can be challenged (an un-enrolled share_code is 404). Enrollment is a one-time consent on the web.allme.fyi/auth surface via the OAuth helper's 2fa_enroll mode — a redirect button (oauth.AuthorizeURL("2fa_enroll", &companydata.AuthorizeURLOptions{State: state})), or server-to-server with ResponseMode: "detached" + PollResult(state, 0, 0), which returns {"enrolled": true, "state": ...} once the person confirms.
  • Errors. 404 (unknown / not-enrolled share code). A 429 is either the plain rate limit (retried with backoff → *RateLimitError) or twofa.pending_cap (too many challenges already open for this person) — the latter surfaces immediately as *ApiError and 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

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL