Skip to content

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). Matches data.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, or null while 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 prefer verdict in integrations.
    • phase — which half of the two-phase flow the event reports: technical (parse — session.parsed, session.failed with stage = parse), context (the context gate — session.failed with stage = context_fetch / context_validation), content (everything after the gate — session.transformed, session.checked, session.failed with stage = transform / validation_gating / validate / check), null on 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 the technical events: parse issues with severity error across all files (a file that failed to parse at all counts as one) and issues with severity warning; null on every other event. Type-specific fields:
    • session.exported.data.export.{package_name, artifacts} — package metadata. artifacts[*] carries filename, role, sha256, row_count, size_bytes. No download URL — fetch the package via the authenticated API at GET /api/sessions/{session_id}/export/download with your API key.
    • session.failed.data.{stage, reason, message} — stage is one of context_validation, context_fetch, parse, transform, validate, validation_gating, check; reason is a short error code (e.g. context_invalid, context_fetch_failed, context_fetch_timeout, parse_error, transform_error, system_error, blocking_violations, check_error); message is a human-readable explanation, or null. validation_gating / blocking_violations means 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, and verdict is failed. context_fetch / context_validation means 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.parsed arrived earlier), nothing after it executed, so there are no content findings to fetch and verdict is null unless parse reported warnings; message names 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 reaching session.checked produces a session.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.exported event 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-id and data.id — for non-ping events the envelope-level id is the delivery id (changes on replay); the inner data.session_id / data.project_id are the domain entity ids and do not change. Persist deliveries by (endpoint_id, webhook-id); persist business state by data.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.