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.
?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 | |
|---|---|---|
| Latency | As often as you poll (poll changes freely — its rate limit is generous) | Near-real-time push |
| You need | A scheduled job / loop that calls the API | An endpoint reachable from the API environment |
| Delivery model | Drain-on-fetch (you pull, the server deletes what it returned) | Server POSTs, expects HTTP 200 |
| If your side is down | Events stay queued (7-day TTL) until you fetch | Circuit-breaker parks a webhook-specific backlog; auto-replays on recovery |
| SDK support | The crash-safe changes pump | The 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.
Configuration fields
| Field | Values | Notes |
|---|---|---|
url | An https:// URL on port 443 to a public address | No user-info, no localhost, and no loopback, private or link-local address. The host is resolved again at every delivery and must resolve only to public addresses; redirects are not followed. Anything else is refused 400 services.webhook_url_invalid. |
events | A subset of the event types | An empty list means all events. |
format | json (default) · xml | The wire format of the POSTed body. The SDK helpers handle both. |
auth_type | hmac (default) · bearer · basic · header · none | How allus authenticates to your endpoint — see Authenticating the delivery. |
encrypt_payload | true · false | Independent toggle, combinable with any auth mode — wraps the whole body in the account-key envelope. |
enabled | true · false | Disabled webhooks receive nothing. |
•••• + 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:
| Event | When | Carries |
|---|---|---|
connection_created | A person connects to your service | service + person + ts (no slot, no value) |
connection_deleted | A person disconnects the service, or your service ends the connection | service + person + ts (no slot, no value) |
field_updated | A person edits a live-mapped field, or a live-mapped field gains or loses its verification with its value unchanged | the request slot + the new value (one event per affected slot) |
field_deleted | A live mapping is dropped on re-map, or the person deletes the live-mapped value or field itself | the request slot (no value) |
consent_accepted | A person provides a requested field on a live connection | per request slot |
consent_declined | A person declines a requested field (grandfathered) | per request slot |
document_status_changed | A 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 it | document_id + the new status + an action (signed|accepted|cancelled, or null); no slot, no value |
document_created / document_updated / document_deleted | A per-person document of your service was created (also by a contract-flow generation), changed or deleted | document_id + the document's name, kind, payload_kind + person + ts; no slot, no value |
connection_request_accepted | A person accepts a connect request you sent | request_id + person + ts (no slot, no value). connection_created also fires |
connection_request_rejected | A person rejects a connect request you sent | request_id + person + ts (no slot, no value) |
flow_run_turn | A contract-flow run became a party's turn | flow_run_id + awaiting_party + person + ts (run-level; no slot, no value) |
flow_completed | A contract-flow run reached its terminal state | flow_run_id + person + ts (run-level; no slot, no value) |
2fa_challenge_completed | A 2FA by allme challenge your service raised was approved, denied or revoked | challenge_id + status + completed_at + person + ts; no slot, no value. Your poll of the challenge stays the record |
key_rotated | Reserved — 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_received | A connected person sent your service a message. Person→company only — your own sends, a broadcast included, raise no event | connection_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 |
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"
}signed/accepted— the person signed / accepted the contract (statusbecomesactive). On a signature the event also carries the audit inline —method,content_sha256,signed_at— so you never need a follow-up document GET.cancelled— the person cancelled a cancellable document/contract (statusbecomesactive_but_endingorended); the signature fields arenull. The event carriescancel_effective_date— the date the cancellation takes effect (a future date ⇒active_but_endinguntil then; today ⇒ended) — so you know when without a GET.
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"
}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"
}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"
}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.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:
| Mode | How allus authenticates to you |
|---|---|
hmac default | An X-Allus-Signature header = hex(HMAC-SHA256(raw_body, hmac_secret)). Recommended. |
bearer | An Authorization: Bearer <auth_token> header with your stored token. |
basic | HTTP Basic with your stored auth_username + auth_password. |
header | A custom header: auth_header_name: auth_header_value. |
none | No 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);
}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.
| Helper | Does |
|---|---|
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 200import 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);
}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:
- A random AES-256 key is RSA-OAEP-wrapped to your account public key (
k), and the body is AES-256-GCM-encrypted under it (iv+d). - You decrypt with the account private key — the PEM you download from portal → Settings → Account key (
GET /api/account-key). The SDK reads it fromaccount_private_key+account_passphrasein your config and unwraps automatically insideparseWebhook/handleWebhook.
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.
- Healthy (running): allus POSTs each event as it happens. Your endpoint returns
200. - 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_changesrow tagged with the webhook id) instead of being pushed. Nothing is lost. - 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.
- 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.
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.
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.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
- Portal guides → Webhooks — the illustrated walkthrough for configuring an endpoint.
- SDKs — install an SDK and use the receiver helpers.
- Service API → change feed — the pull alternative, same events.
- Open the service API spec ↗