Webhooks reference¶
This page is the contract between Expecto and a webhook consumer: which events ship, what the envelope looks like, how to verify signatures, and how to behave on failures. If you are setting up endpoints from the UI, start with the Org Admin guide and treat this page as the spec for whoever writes the receiver.
Expecto follows the Standard Webhooks
HTTP signing convention: a versioned HMAC-SHA256 over
id.timestamp.body, sent in the webhook-signature header.
Events¶
Session lifecycle, context-fetch, comment and job events, plus a synthetic
webhook.ping. Every event fires immediately after Expecto commits the new
state — so the database is consistent with what the payload describes.
| Type | Trigger |
|---|---|
session.created |
A session is created. |
session.uploaded |
A file is uploaded for a session. Fires once per uploaded file. |
session.parsed |
All required files parsed without errors — the technical result. Does not wait for the context fetch; carries parse_error_count / parse_warning_count. |
session.transformed |
All transformers produced output without errors. |
session.checked |
Project business rules ran without errors. |
session.confirmed |
Submitter confirmed the data. |
session.approved |
Approver gave final sign-off (only in dual approval mode). |
session.exported |
Export package generated and stored. |
session.failed |
A run ended without reaching session.checked: a stage failed, or the run halted at the context gate — after parse, before transform (context fetch failed or timed out, invalid context). Payload includes data.stage, data.reason, data.message and data.phase. |
session.context_fetch_started |
Automatic context fetch was enqueued at session creation (project has integration context sources). |
session.context_fetch_completed |
The automatic context fetch finished successfully. |
session.context_fetch_failed |
The automatic context fetch failed (the pipeline is blocked until re-run). |
session.comment_added |
A user added a comment to a session. |
job.updated |
An async job (pipeline run / export / import) changed status. Payload carries data.{job_id, job_type, status, session_id}. Org-level subscription only. |
webhook.ping |
Synthetic ping sent on demand from the UI / API. |
Realtime UI vs webhooks
These same events also drive the in-app realtime UI over Server-Sent Events
(GET /api/events/stream, org-scoped, bearer-authenticated) — so the console
updates without polling. Webhooks are for your downstream systems;
subscription to any event above is opt-in per endpoint.
session.approved is skipped by design when the project uses single
approval mode — session.confirmed is followed directly by export.
Envelope shape¶
Every delivery is a single JSON object with this shape:
{
"id": "618d0e8d-22c7-4a4a-9cdb-20fc9510921f",
"type": "session.exported",
"timestamp": "2026-06-09T13:30:11.859321+00:00",
"data": {
"session_id": "7c3a76c0-6a00-44a5-a0ad-9093e6d219e1",
"organization_id": "6042680b-e742-4628-8b62-3d16c4e6e5a9",
"project_id": "201569bb-5171-4ad0-bfc7-ab8740d6bf37",
"data_contract_id": "f46976f3-700d-49c8-979d-e0ffbeb1ff04",
"data_provider_id": null,
"period": "2026-06",
"status": "exported",
"health": "completed",
"verdict": "passed",
"phase": null,
"parse_error_count": null,
"parse_warning_count": null,
"occurred_at": "2026-06-09T13:30:11.859321+00:00",
"export": {
"package_name": "Webhook_E2E_demo_2026-06_20260609_133011.zip",
"artifacts": [
{
"filename": "echo.csv",
"role": "echo",
"row_count": 3,
"sha256": "8d8c559fe7f3f9f26919a438bde92fddf03099630559c3eedfd92fce0a7bf3c1",
"size_bytes": 44
}
]
}
}
}
id— UUID of the delivery. Stable across retries of the same attempt; regenerated on a replay. Use this as the idempotency key on your side.type— one of the event types above.timestamp— ISO-8601 of the event's logical time (when Expecto committed the state). Matchesdata.occurred_at.-
data— event-specific payload. Identifier fields above (session_id,organization_id,project_id,data_contract_id,data_provider_id,period,status,health,verdict,phase,parse_error_count,parse_warning_count,occurred_at) are present on every session event. Three of them are easy to confuse:status— how far the session got in the pipeline (created…exported).verdict— the outcome of the automated checks:passed,warnings,failed, ornullwhile there is none yet. This is the field to react to. It is outcome-only and stable: a warning is never hidden by a later stage.health— the dashboard chip (created,in_progress,blocked,attention,ready,completed). It mixes progress with outcome and may gain values as the UI grows, so preferverdictin integrations.phase— which half of the two-phase flow the event reports:technical(parse —session.parsed,session.failedwithstage = parse),context(the context gate —session.failedwithstage = context_fetch/context_validation),content(everything after the gate —session.transformed,session.checked,session.failedwithstage = transform/validation_gating/validate/check),nullon every other event. Map it to a picklist when the receiving system tracks the technical and the content result separately.parse_error_count/parse_warning_count— integers on thetechnicalevents: parse issues with severityerroracross all files (a file that failed to parse at all counts as one) and issues with severitywarning;nullon every other event. Type-specific fields:session.exported.data.export.{package_name, artifacts}— package metadata.artifacts[*]carriesfilename,role,sha256,row_count,size_bytes. No download URL — fetch the package via the authenticated API atGET /api/sessions/{session_id}/export/downloadwith your API key.session.failed.data.{stage, reason, message}—stageis one ofcontext_validation,context_fetch,parse,transform,validate,validation_gating,check;reasonis a short error code (e.g.context_invalid,context_fetch_failed,context_fetch_timeout,parse_error,transform_error,system_error,blocking_violations,check_error);messageis a human-readable explanation, ornull.validation_gating/blocking_violationsmeans entity validation found violations at or above the project's blocking severity, so the run ended before the business-rule check — the data did not pass, andverdictisfailed.context_fetch/context_validationmeans the run halted at the context gate — after parse, before transform: the automatic context fetch had failed (or timed out while the run waited for it), or the merged session context does not match the project's schema under strict validation. Parse already ran (session.parsedarrived earlier), nothing after it executed, so there are no content findings to fetch andverdictisnullunless parse reported warnings;messagenames the failed context sources and their errors (e.g.Halted: context fetch is failed (sap_prev_balance: BuiltAPI rejected the connection credentials (401))). A pending fetch no longer produces an event: the run waits for it and continues by itself. Every way a run can end without reachingsession.checkedproduces asession.failed; a subscriber never has to infer a halt from silence.webhook.ping.data.{sent_at, sent_by_user_id}— for connectivity testing.
HTTP headers¶
Every POST carries:
| Header | Example | Meaning |
|---|---|---|
webhook-id |
618d0e8d-22c7-4a4a-9cdb-20fc9510921f |
Delivery UUID. Stable across retries, regenerated on replay. |
webhook-timestamp |
1781008807 |
Unix seconds when the body was signed. |
webhook-signature |
v1,WtkBkfcIcZB3iHOB+wE/jCeCnIkMjNw7BSiaWmHj7Kg= |
Signature line. Comma-separated version,base64-HMAC. |
content-type |
application/json |
Body is always JSON. |
user-agent |
Expecto-Webhooks/0.0.1 |
Stable prefix; version may bump. |
The body is always UTF-8 JSON. Expecto does not follow redirects.
Verifying the signature¶
The signing string is:
<webhook-id>.<webhook-timestamp>.<body>
— where <body> is the exact bytes of the request body, and the
secret used to sign is the whsec_… plaintext that was shown to the
admin on endpoint creation (or rotation).
The header value is v1,<base64-standard-HMAC-SHA256>. Multiple
signatures may be space-separated in the future (for example during
secret rotation overlap); reject the request unless at least one
verifies.
Reject any request whose webhook-timestamp differs from the current
time by more than 5 minutes (webhook_signature_tolerance_seconds,
default 300). This blocks naive replay of captured payloads.
Python (FastAPI / Flask / Django)¶
import base64
import hashlib
import hmac
import time
WEBHOOK_TOLERANCE_SECONDS = 5 * 60
def verify(secret: str, headers: dict, body: bytes) -> dict:
webhook_id = headers["webhook-id"]
webhook_timestamp = headers["webhook-timestamp"]
webhook_signature = headers["webhook-signature"]
if abs(int(time.time()) - int(webhook_timestamp)) > WEBHOOK_TOLERANCE_SECONDS:
raise ValueError("Timestamp outside tolerance window")
# secret has form "whsec_<base64>" — Standard Webhooks does NOT base64-decode
# the prefix; Expecto signs with the raw plaintext bytes.
secret_bytes = secret.encode("utf-8")
signed_payload = f"{webhook_id}.{webhook_timestamp}.{body.decode('utf-8')}".encode("utf-8")
expected = base64.b64encode(hmac.new(secret_bytes, signed_payload, hashlib.sha256).digest()).decode("ascii")
for sig in webhook_signature.split(" "):
version, _, value = sig.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
import json
return json.loads(body)
raise ValueError("No matching signature")
Node.js / Express¶
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verify(secret, headers, rawBody) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigHeader = headers["webhook-signature"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SECONDS) {
throw new Error("Timestamp outside tolerance window");
}
const signed = `${id}.${ts}.${rawBody.toString("utf-8")}`;
const expected = crypto.createHmac("sha256", secret).update(signed).digest("base64");
for (const sig of sigHeader.split(" ")) {
const [version, value] = sig.split(",");
if (version === "v1" && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected))) {
return JSON.parse(rawBody.toString("utf-8"));
}
}
throw new Error("No matching signature");
}
Use the raw bytes
Most web frameworks parse JSON before your handler runs. Capture the
raw request body before parsing — re-serializing the parsed object
will produce different bytes and the signature will not match. Express
needs express.raw({type: 'application/json'}); FastAPI needs the
request.body() coroutine before request.json() is called; Django
needs request.body before request.POST is touched.
Optional auth header¶
An endpoint can carry an optional (auth_header_name, auth_header_value)
pair. When set, every outgoing request — including pings, retries, and
replays — adds the configured header alongside the Standard Webhooks
signing headers. Use this when your receiver sits behind a reverse proxy
or API gateway that requires Authorization: Bearer …,
X-API-Key: …, or similar.
The value is encrypted with the same Fernet key as the signing secret
(webhook_signing_key) and decrypted only at dispatch time. Read APIs
return the header name and a auth_header_configured boolean; the
plaintext value is never returned after creation.
The header name must match ^[A-Za-z][A-Za-z0-9_-]{0,63}$ and must not
collide with headers the system already controls
(webhook-id, webhook-timestamp, webhook-signature, host,
content-length, content-type, user-agent).
The auth header complements signature verification — it does not replace it. The signature still authenticates the sender. The receiver should still verify both the signing header and (independently) anything its proxy enforces.
Retries and timeouts¶
Expecto considers any HTTP response outside 2xx as a failure (including 5xx, 3xx redirects, and transport errors). On failure it schedules a retry with exponential backoff:
- Base delay: 30 s (
webhook_backoff_base_seconds) - Cap: 1 h (
webhook_backoff_cap_seconds) - Max attempts: 6 (
webhook_max_attempts) - Per-(delivery, attempt) jitter so retries from many endpoints do not synchronize.
- Request timeout: 30 s (
webhook_request_timeout_seconds). - Response excerpt: Expecto reads at most 4 KiB of the response body and stores it on the delivery row for diagnostics. Keep your response small.
After the 6th failed attempt the delivery enters dead-letter. The
endpoint's consecutive_failures counter increments; one successful
delivery resets it to zero. After 10 consecutive dead-letters the
endpoint is auto-disabled and stops receiving events until an admin
re-enables it. See the Org Admin guide.
Idempotency¶
Treat webhook-id as the idempotency key on your side. Retries reuse
the same webhook-id; a replay from the UI mints a new webhook-id
(and re-signs the body so signature verification still holds) — that
is intentional, so you can re-drive a delivery without the receiver
silently deduplicating it away.
If your consumer must enforce exactly-once delivery, store
(endpoint_id, webhook-id) and reject duplicates. Expecto's worker is
already idempotent on its side: a delivery whose status is already
succeeded is short-circuited and not re-sent.
Operational notes¶
- Pings (
webhook.ping) carry no business data — use them only for connectivity and signature-verification tests. - Replays of a
session.exportedevent re-send the same artifact metadata; the package itself does not change. If you have already downloaded and processed the package, you do not need to refetch it. webhook-idanddata.id— for non-ping events the envelope-levelidis the delivery id (changes on replay); the innerdata.session_id/data.project_idare the domain entity ids and do not change. Persist deliveries by(endpoint_id, webhook-id); persist business state bydata.session_id.
Verifying through the UI¶
The fastest way to confirm that your receiver is wired correctly is
Send ping in the Org Admin Webhooks
tab. Expecto signs a webhook.ping envelope with the endpoint's current
secret, POSTs it, and surfaces the response code and the response excerpt
in the delivery log within a few seconds. If verification fails on the
consumer side, returning 400 with a short explanation in the body is
the most useful response — it lands in last_response_excerpt and
makes the cause visible in the UI.
Outbound bindings (push into external platforms)¶
Webhook endpoints deliver a signed envelope to your HTTP receiver. When the
target is a third-party platform with its own API — Salesforce today — use a
project outbound binding instead: Expecto authenticates against the
platform and writes a record shaped by an adapter-specific mapping, rather
than POSTing the generic envelope. Binding deliveries share this machinery —
the same retry/backoff cycle, dead-letter, replay, and history
(GET /api/webhook-deliveries, filterable by connection_id).
The full guide — preparing the Salesforce org, creating the connection, the
mapping with required_fields and value_maps, which events carry run_id
and verdict, error semantics, and troubleshooting — lives in
Integrations › Salesforce; the placeholder
vocabulary in the placeholders & delivery reference.