Theme
Open the portal

Webhooks

Webhooks are the low-latency push counterpart to the pull change feed: instead of polling GET /api/company-data/changes, allus POSTs each person→service change to an endpoint you configure per service. Same events, same payload contents, same encryption contract.

Two channels — consume one. When your service has a registered API client, every change lands in the pull feed; every webhook that subscribes to it receives it as well, independently. A webhook that is failing parks its own events in a backlog you can also drain with ?webhook_id=. The two channels share no identity, so a consumer reading both cannot reconcile them — pick polling or webhooks.

When to use webhooks

The pull change feed and webhooks deliver the identical event stream. Choose based on latency and your infrastructure:

Pull feed (GET /changes)Webhooks
LatencyAs often as you poll (poll changes freely — its rate limit is generous)Near-real-time push
You needA scheduled job / loop that calls the APIAn endpoint reachable from the API environment
Delivery modelDrain-on-fetch (you pull, the server deletes what it returned)Server POSTs, expects HTTP 200
If your side is downEvents stay queued (7-day TTL) until you fetchCircuit-breaker parks a webhook-specific backlog; auto-replays on recovery
SDK supportThe crash-safe changes pumpThe webhook receiver helpers

Both paths carry the same ciphertext values (encrypted for your service key) and the same payload shape. You can switch from polling to webhooks (or back) without changing how you decrypt or process events — but consume one channel, not both, and note that the id field means different things on each (see the delivery guarantee).

Configuring a webhook

Webhooks are configured per service in the portal (Configuration → Services → your service → Webhooks) or via the portal API (POST /api/services/{id}/webhooks). A service can have multiple webhook endpoints. Step-by-step with screenshots: Portal guides → Webhooks.

Portal → Configuration → Services → service → Webhooks tab
Portal → Configuration → Services → service → Webhooks tab — Add an endpoint: URL, the events to subscribe to, the wire format, the auth mode, and the optional account-key envelope toggle.

Configuration fields

FieldValuesNotes
urlAn https:// or http:// URL with a hostUse HTTPS for a real endpoint; http:// is accepted so a local receiver works in development. Anything else is refused 400 services.webhook_url_invalid.
eventsA subset of the event typesAn empty list means all events.
formatjson (default) · xmlThe wire format of the POSTed body. The SDK helpers handle both.
auth_typehmac (default) · bearer · basic · header · noneHow allus authenticates to your endpoint — see Authenticating the delivery.
encrypt_payloadtrue · falseIndependent toggle, combinable with any auth mode — wraps the whole body in the account-key envelope.
enabledtrue · falseDisabled webhooks receive nothing.
Secrets are write-only. The HMAC secret, bearer token, basic credentials and custom header value are stored as-is (they must be replayed verbatim on delivery) and are always masked in API responses (•••• + the last 4 characters). A PUT that omits a secret keeps the stored value. An omitted HMAC secret is auto-generated and returned once as hmac_secret_once — capture it then; it is never shown again.

Payload

allus POSTs each change to your endpoint; a field_updated / field_deleted carries the slot's state as it is when the event is delivered. The body is serialized as JSON or XML per the webhook's format. Every webhook delivery carries one change event — the same shape as a single row from the pull change feed.

The events

The event vocabulary is identical to the pull feed. The event field is one of:

EventWhenCarries
connection_createdA person connects to your serviceservice + person + ts (no slot, no value)
connection_deletedA person disconnects the service, or your service ends the connectionservice + person + ts (no slot, no value)
field_updatedA person edits a live-mapped field, or a live-mapped field gains or loses its verification with its value unchangedthe request slot + the new value (one event per affected slot)
field_deletedA live mapping is dropped on re-map, or the person deletes the live-mapped value or field itselfthe request slot (no value)
consent_acceptedA person provides a requested field on a live connectionper request slot
consent_declinedA person declines a requested field (grandfathered)per request slot
document_status_changedA person signs, accepts or cancels a document/contract you issued (in-app), or — on a contract-flow run — the signing line opens a step's copy or a run cancel ends itdocument_id + the new status + an action (signed|accepted|cancelled, or null); no slot, no value
document_created / document_updated / document_deletedA per-person document of your service was created (also by a contract-flow generation), changed or deleteddocument_id + the document's name, kind, payload_kind + person + ts; no slot, no value
connection_request_acceptedA person accepts a connect request you sentrequest_id + person + ts (no slot, no value). connection_created also fires
connection_request_rejectedA person rejects a connect request you sentrequest_id + person + ts (no slot, no value)
flow_run_turnA contract-flow run became a party's turnflow_run_id + awaiting_party + person + ts (run-level; no slot, no value)
flow_completedA contract-flow run reached its terminal stateflow_run_id + person + ts (run-level; no slot, no value)
2fa_challenge_completedA 2FA by allme challenge your service raised was approved, denied or revokedchallenge_id + status + completed_at + person + ts; no slot, no value. Your poll of the challenge stays the record
key_rotatedReserved — a person's encryption keypair is never replaced, so it is not emitted (key rotation)person + share_code + public_key_sha256 + ts; no slot, no value
message_receivedA connected person sent your service a message. Person→company only — your own sends, a broadcast included, raise no eventconnection_id + message_id + person_public_key + created_at and the message ciphertext under body (encrypted to your service key), never under value; no slot. Identical on the pull feed
A single source field can answer multiple request slots, so one edit can produce one field_updated event per slot. Process each event independently against its slug (the request slot).

Body shape

A field_updated event for a text field carries the new value inline as ciphertext (the {"_enc":1,…} wrapper, encrypted for your service key):

{
  "id": "chg-…",                      // NOT a dedup key on this channel — see Delivery guarantee
  "event": "field_updated",
  "person_user_id": "…",
  "share_code": "ABC123",             // the person's profile share code (every event; may be null)
  "slug": "work_email",               // YOUR request slug — never the person's source field
  "live": true,                       // false = a one-time / snapshot answer
  "value": "{\"_enc\":1,\"k\":\"…\",\"iv\":\"…\",\"d\":\"…\"}",  // ciphertext for the SERVICE key
  "at": "2026-06-16T13:00:00Z"
}

A field_updated for a binary field (photo / document / legal document) carries a value_url instead of value — fetch it with your client token. What comes back is either the encrypted wrapper to decrypt off-platform or the raw file bytes, depending on whether the person's source field is private; branch on Content-Type (details):

{
  "id": "chg-…",
  "event": "field_updated",
  "person_user_id": "…",
  "share_code": "ABC123",
  "slug": "id_photo",
  "live": true,
  "value_url": "https://api.allme.fyi/api/company-data/connections/{connectionId}/slots/{serviceFieldId}/file",
  "at": "2026-06-16T13:00:00Z"
}

The connection-level events (connection_created, connection_deleted) carry just id, event, person_user_id, share_code, customer_type and at — no slug or value. On connection_created, fetch the connection's provided fields from GET /api/company-data/connections/{id}. The consent events (consent_accepted / consent_declined) add the resolved slot's slug, no value. The payload is slug-keyed and never includes the person's source field_id.

A document_status_changed event is its own shape — keyed by document_id, the new status and an action, with no slug, no value and no source field. It fires when the person acts on a document or contract you issued, in-app. On a contract-flow run it ALSO fires — with no action — when a signature advances the run's signing line and the next step's copy opens as a result: that event's status is the newly-opened copy's ready_to_sign or offering.

{
  "id": "chg-…",                      // NOT a dedup key on this channel — see Delivery guarantee
  "event": "document_status_changed",
  "person_user_id": "…",
  "share_code": "ABC123",
  "document_id": "doc-…",             // the document you created via /api/company-data/documents
  "status": "active",                 // offering | ready_to_sign | active | active_but_ending | ended
  "action": "signed",                 // signed | accepted | cancelled
  "method": "biometric",              // sign audit: biometric | twofa | email | custodian (null for accept/cancel)
  "content_sha256": "…",              // SHA-256 of the signed content (null when not a signature)
  "signed_at": "2026-06-22T12:47:41Z",// when the signature was recorded (null when not a signature)
  "cancel_effective_date": "2026-07-30",// on a cancellation: when it takes effect (null otherwise)
  "sealed_at": "2026-06-22T12:47:45Z", // set once the platform seal is applied (null until sealed)
  "plain_sha256": "3b9ff1a2c4…",       // SHA-256 of the document's unencrypted PDF bytes (null on a json contract)
  "signer_first_name": "Alice",        // the latest signature's own signer evidence
  "signer_last_name": "Jansen",
  "signer_name_verified": true,        // true iff that name matched the signer's verified ID name
  "at": "2026-06-22T12:47:41Z"
}
A transition to active additionally carries the document's own sealed_at and plain_sha256, and the triggering signature's signer_first_name, signer_last_name and signer_name_verified — the same seal state a document read carries, so you never need a follow-up GET to learn a run sealed. null on every other transition.
document_status_changed collapses per document: the pending row for a document holds its latest status, so if it moves through several statuses between two drains you receive one event carrying the final one — not one event per transition. Different documents never share a row. Your own PUT /api/company-data/documents/{id} status edits never emit it; they emit document_updated.

The connect-request events (connection_request_accepted / connection_request_rejected) are their own slug-free shape — keyed by the request_id you got back from POST /api/company-data/connect-requests, with no slug and no value:

{
  "id": "chg-…",                          // NOT a dedup key on this channel — see Delivery guarantee
  "event": "connection_request_accepted", // or "connection_request_rejected"
  "request_id": "req-…",                  // the id POST /connect-requests returned
  "person_user_id": "…",
  "share_code": "ABC123",                 // the person's profile share code
  "at": "2026-06-22T13:00:00Z"
}
On accept, a connection_created event also fires for the connection itself (pull its provided fields from GET /api/company-data/connections/{id}). The connect-request events share the connection identity with connection_created/connection_deleted, so per person they collapse to the latest state: if a request is sent and then accepted between two of your drains, you receive the outcome only — not both the acceptance and the connection_created that accompanies it. That final row is the correct current state of the connection. Match the outcome back to your sent invitation with request_id.

The contract-flow run events (flow_run_turn / flow_completed) are run-level and slug-free — keyed by flow_run_id, with no slug and no value. flow_run_turn adds the awaiting_party now owed a turn:

{
  "id": "chg-…",                  // NOT a dedup key on this channel — see Delivery guarantee
  "event": "flow_run_turn",       // or "flow_completed"
  "person_user_id": "…",
  "share_code": "ABC123",
  "flow_run_id": "run-…",
  "awaiting_party": "company",    // flow_run_turn only — the party now owed a turn
  "at": "2026-06-22T13:00:00Z"
}
See Contract flows → events for what each run event means and how to drive a run. The person's source field_id is never exposed on a run event.

A message_received event is its own slug-free shape — the person's message to your service, with everything you need to answer it and to acknowledge it. It fires on a person→company message only; your own sends, a broadcast included, raise no event:

{
  "id": "chg-…",                      // NOT a dedup key on this channel — see Delivery guarantee
  "event": "message_received",
  "person_user_id": "…",
  "share_code": "ABC123",
  "connection_id": "019xxxx",         // reply and acknowledge on this connection
  "message_id": "019yyyy",            // pass this as the acknowledgement boundary
  "person_public_key": "<base64 SPKI>", // encrypt your reply to it — no second key fetch needed
  "body": "{\"_enc\":1,\"k\":\"…\",\"iv\":\"…\",\"d\":\"…\"}",  // ciphertext for the SERVICE key
  "created_at": "2026-08-11T09:12:00Z",
  "at": "2026-08-11T09:12:00Z"
}
Receiving is not acknowledging. The message stays unread until you call POST …/connections/{connectionId}/messages/read with the message_id as the boundary — and sending a reply does not do it for you. A service that never acknowledges lets its unread count grow without bound, and the person never sees a read receipt.
Inline values are always ciphertext. Text values are encrypted for your service public key (zero-knowledge — the platform never sees the plaintext). You decrypt off-platform with your service private key. There are never base64 bytes in the payload: a binary answer is a value_url you fetch separately, and that fetch is the one exception to "always ciphertext" — an answer whose source field is not private is served as the plaintext file, because no encrypted copy of it exists (why). The SDKs handle both shapes; the raw wrapper format is in Reference → Encryption.

Authenticating the delivery

Your endpoint should authenticate that a request genuinely came from allus. Pick one auth_type:

ModeHow allus authenticates to you
hmac defaultAn X-Allus-Signature header = hex(HMAC-SHA256(raw_body, hmac_secret)). Recommended.
bearerAn Authorization: Bearer <auth_token> header with your stored token.
basicHTTP Basic with your stored auth_username + auth_password.
headerA custom header: auth_header_name: auth_header_value.
noneNo authentication (use only behind your own gateway).

Verifying the HMAC signature

For the default hmac mode, recompute HMAC-SHA256 over the raw request body (exactly the bytes received, before any JSON parsing) using your webhook's HMAC secret, then constant-time-compare the hex digest to the X-Allus-Signature header:

import hmac, hashlib

def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header)
import { createHmac, timingSafeEqual } from "crypto";

function verify(rawBody: Buffer, signatureHeader: string, secret: string): boolean {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(signatureHeader);
  return a.length === b.length && timingSafeEqual(a, b);
}
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
)

func verify(rawBody []byte, signatureHeader, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signatureHeader))
}
using System.Security.Cryptography;
using System.Text;

bool Verify(byte[] rawBody, string signatureHeader, string secret) {
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = Convert.ToHexString(hmac.ComputeHash(rawBody)).ToLowerInvariant();
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signatureHeader));
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;

boolean verify(byte[] rawBody, String signatureHeader, String secret) throws Exception {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
    StringBuilder sb = new StringBuilder();
    for (byte b : mac.doFinal(rawBody)) sb.append(String.format("%02x", b));
    return MessageDigest.isEqual(sb.toString().getBytes(), signatureHeader.getBytes());
}
function verify(string $rawBody, string $signatureHeader, string $secret): bool {
    $expected = hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $signatureHeader);
}
Sign the raw bytes, not the re-serialized object. Compute the HMAC over the body exactly as it arrived. Parsing JSON and re-stringifying it can change whitespace or key order and break verification.

SDK webhook helpers

The six SDKs include webhook receiver helpers so you never hand-roll the verification or decryption. All secrets and keys come from your config.json — the helpers take no key or secret arguments. The SDK verifies each delivery against whichever auth_type you set on the webhook — put the matching field in your config (at most one): HMAC webhooks/webhook_secret, webhook_bearer_token, webhook_basic {"username","password"}, webhook_header {"name","value"}, or webhook_auth_none. Two or more → ConfigError. For HMAC, the delivery's X-Allus-Webhook-Id header selects that webhook's secret.

HelperDoes
verifyWebhook(rawBody, headers)Verifies the delivery against your configured auth method — HMAC (X-Allus-Signature), bearer, basic, custom header, or none — with a constant-time compare. Returns a boolean.
parseWebhook(rawBody, headers)Returns a Change (the same shape as a feed event). Unwraps the account-key envelope first if present, then decrypts inner field values with your service key. Handles JSON and XML.
handleWebhook(rawBody, headers)Verify + parse in one call; returns the Change or raises WebhookError on a bad signature. The typical one-liner in your route.

A complete receiver, in each language:

from flask import Flask, request
from allus_company_data import Client, WebhookError

app = Flask(__name__)
client = Client.from_config("config.json")

@app.post("/allus/webhook")
def webhook():
    try:
        change = client.handle_webhook(request.get_data(), dict(request.headers))
    except WebhookError:
        return ("bad signature", 401)
    # change.event, change.slug, change.value (decrypted), change.live …
    process(change)
    return ("", 200)   # MUST return HTTP 200
import express from "express";
import { Client, WebhookError } from "@allus-fyi/company-data";

const app = express();
const client = Client.fromConfig("config.json");

// 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, req.headers);
  } catch (e) {
    if (e instanceof WebhookError) return res.sendStatus(401);
    throw e;
  }
  process(change);          // change.event, change.slug, change.value …
  res.sendStatus(200);      // MUST return HTTP 200
});
func webhook(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    change, err := client.HandleWebhook(body, r.Header)
    if err != nil {           // WebhookError on a bad signature
        w.WriteHeader(http.StatusUnauthorized)
        return
    }
    process(change)           // change.Event, change.Slug, change.Value …
    w.WriteHeader(http.StatusOK)   // MUST return HTTP 200
}
app.MapPost("/allus/webhook", async (HttpRequest req) => {
    using var reader = new StreamReader(req.Body);
    var body = await reader.ReadToEndAsync();
    var headers = req.Headers.ToDictionary(h => h.Key, h => (string)h.Value!);
    try {
        var change = client.HandleWebhook(body, headers);
        Process(change);            // change.Event, change.Slug, change.Value …
        return Results.Ok();        // MUST return HTTP 200
    } catch (WebhookException) {
        return Results.Unauthorized();
    }
});
@PostMapping("/allus/webhook")
ResponseEntity<Void> webhook(@RequestBody byte[] body,
                             @RequestHeader Map<String,String> headers) {
    try {
        Change change = client.handleWebhook(body, headers);
        process(change);                       // change.event, change.slug …
        return ResponseEntity.ok().build();    // MUST return HTTP 200
    } catch (WebhookException e) {
        return ResponseEntity.status(401).build();
    }
}
$client = Client::fromConfig('config.json');

// in your route handler:
$raw = file_get_contents('php://input');
try {
    $change = $client->handleWebhook($raw, getallheaders());
    process($change);          // $change->event, $change->slug, $change->value …
    http_response_code(200);   // MUST return HTTP 200
} catch (WebhookError $e) {
    http_response_code(401);
}
Respond with exactly HTTP 200. allus treats only a 200 as success. A 202, 204, a 3xx redirect, or any 4xx/5xx is treated as a failure and trips the circuit-breaker. Acknowledge fast (return 200, then process asynchronously if your work is slow) — your endpoint is one in-flight request at a time per webhook.

The account-key envelope (encrypt_payload)

With encrypt_payload enabled, allus wraps the entire serialized body in an extra encryption layer keyed to your company account public key (not the service key). This protects the payload metadata in transit beyond TLS. The wrapper is the same {"_enc":1,"k","iv","d"} shape:

The envelope uses OAEP with SHA-1 (MGF1-SHA1) — OpenSSL's default. This is webhook-specific and deliberately different from the SHA-256 OAEP used for the inline field-value wrapper. If you decrypt the envelope yourself (not via an SDK), use OpenSSL's default OAEP padding — e.g. openssl pkeyutl -decrypt -pkeyopt rsa_padding_mode:oaep. After unwrapping the envelope you still decrypt the inner field values with the service key (SHA-256 OAEP). Most integrators let the SDK handle both layers.

The account-key envelope is independent of the auth mode — you can combine HMAC signing and encrypt_payload on the same webhook.

Delivery, health & recovery

Deliveries are ordered per webhook (one in-flight POST at a time, so connection_created always arrives before that connection's first field_updated). Each endpoint has a health state — running, bad, or unknown — and a self-healing circuit-breaker.

  1. Healthy (running): allus POSTs each event as it happens. Your endpoint returns 200.
  2. A delivery fails (non-200 / connection error): the webhook flips to bad. Its pending and subsequent events are parked in the durable backlog (a company_data_changes row tagged with the webhook id) instead of being pushed. Nothing is lost.
  3. 10-minute grace, then probe: the backlog is the only retry mechanism — there is no per-item exponential backoff and no dead-letter queue. After 10 minutes the webhook goes unknown and allus probes by delivering the oldest backlog row.
  4. Recovery: if the probe returns 200, the webhook returns to running and the entire backlog is replayed in original (FIFO) order, then cleared. If the probe fails, it stays bad and the 10-minute cycle repeats.
Manual recovery. While a webhook is down, you can drain its parked backlog yourself with the pull API: GET /api/company-data/changes?webhook_id=<id>. By default the pull feed drains only the plain (un-tagged) channel; passing ?webhook_id= drains that specific webhook's backlog instead. This is the alternative to waiting for the auto-replay.

Delivery guarantee — effectively unique, rarely replayed

Each queued event is POSTed once: your 200 acks it and it is dropped. Anything else — a non-200, a timeout, a connection error — is never retried in place; it flushes to the backlog above and comes back through the auto-replay or your manual drain. So deliveries are effectively unique, with exactly one exception: if your endpoint processed an event but allus never saw your 200 (your response timed out, or you crashed after committing and before responding), that event is treated as failed and replayed on recovery — so you receive it again. There is no cap of two. A probe that fails leaves its backlog row in place (step 4 above), so if the replay's 200 is lost the same way, the next probe delivers that event once more. Treat this window as at-least-once and make your handler safe to run repeatedly.

Do not use a webhook's id as an idempotency key. Unlike a pull-feed row's, it is neither reliably stable nor reliably fresh, and your endpoint 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 — and the later replay of the same event is rebuilt from a durable backlog row, so it arrives with a different id. A replayed delivery, however, carries that row's id, and the row stays in the backlog until it is delivered successfully — so a probe that has to be re-attempted 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 (the live delivery replayed) and matches only the rarer one (the re-probed backlog row): it is not a contract. If you need strict idempotency, key on the content: event + person + slug (or document_id / request_id) + payload. The id-dedup guidance on the pull feed applies there only, where id is the real change-row id for the whole life of the row.
Consume one channel, not both. If your service has both a registered API client and a webhook, the platform populates both independently — but there is no shared identity between them, so a consumer reading both cannot reconcile the two copies and will double-apply. Pick webhooks or the pull feed.

Persistent failure surfaces two ways: the service Logs tab records every state transition (running→bad with the HTTP code, bad→unknown, unknown→running with the replay count) and every config change; and the stale-queue email alerts report the webhook backlog age (≥2 days → daily, ≥5 days → hourly, 7 days → the backlog is purged and the data is lost). Deleting a webhook drops its backlog — it is not moved to the pull feed, to avoid duplicate records.

Webhooks vs. push notifications

Webhooks are the only outbound channel for companies. Companies receive no FCM/APNs push notifications — those are person-side only. The portal itself is pull-based; webhooks (and the pull change feed) are how your backend learns about person→service data changes.

Next steps