Webhooks

Holofin can POST to your endpoint when a workflow run completes, fails or pauses for review, when a reviewer confirms its results, and when a dossier changes state. This page covers registering endpoints, the payloads, signatures and retries.

Overview

A webhook is an HTTP POST that Holofin sends to an endpoint you register whenever an event it subscribes to occurs, so you do not have to poll the API.

Note: Webhook endpoints can be configured through your organization settings, or registered programmatically with the Management API below.

How Webhooks Work

  1. Configure endpoint: Register your webhook URL in organization settings
  2. Select events: Choose which events you want to receive
  3. Receive notifications: When events occur, we send POST requests to your endpoint
  4. Process events: Your application handles the webhook payload
  5. Acknowledge receipt: Return a 2xx status code to confirm delivery within 30 seconds

Per-Run Override URL

Registered endpoints receive events for every run in your organization. When you want the events for a single run delivered somewhere else, pass a webhook_url when you create the run with POST /workflow-runs/:

Create a run with a per-run webhook URL

curl -X POST https://app.holofin.ai/api/v1.1/workflow-runs/ \
  -H "X-API-Key: $HOLOFIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "workflow_id": "wf_abc123xyz",
    "document_b64": "JVBERi0xLjQKJeLjz9MKNCAwIG9i...",
    "webhook_url": "https://your-app.example.com/hooks/run-42"
  }'

Behavior:

  • Replaces registered endpoints. For that run, events go only to the override URL; your registered endpoints receive nothing for it. All other runs are unaffected.
  • All events for the run. The override receives the full set: workflow-run.*, node-run.*, extraction.*, and the later workflow-run.confirmed / workflow-run.republished events. Event-subscription filters do not apply.
  • Same envelope, security, and retries. Payloads use the same envelope and flow through the same retry schedule.
  • Signing reuses your endpoint secret. Deliveries are signed with the X-Holofin-Signature header using the secret of your existing webhook endpoint, so the same verification code works. If your organization has no endpoint configured, there is no secret to reuse: deliveries are sent unsigned and the create response includes a warnings entry.
  • Validated up front. The URL must be an http(s) URL reachable from the public internet. Malformed URLs and private/reserved addresses are rejected immediately with 400 INVALID_WEBHOOK_URL (SSRF protection), and the run is not created.
Tip: the override URL is scoped to the run's entire lifetime, including human-review events that can fire hours later. Keep the endpoint reachable for that whole period; a one-shot listener will miss them.

Managing Endpoints (API)

You can register and manage webhook endpoints programmatically instead of using the dashboard, for example to provision one webhook per project. All routes are authenticated with your API key via the X-API-Key header and are scoped to the organization that owns the key.

Method & pathDescription
POST /api/v1.1/webhooksRegister a new webhook endpoint
GET /api/v1.1/webhooksList this organization's endpoints
GET /api/v1.1/webhooks/{id}Fetch a single endpoint
PATCH /api/v1.1/webhooks/{id}Update url, name, events or status
DELETE /api/v1.1/webhooks/{id}Remove an endpoint
Good to know: a maximum of 5 endpoints per organization is enforced. The URL must be publicly reachable over http/https (private and reserved addresses are rejected to prevent SSRF). The signing secret is returned once, in the create response. Store it; it is not shown again.

Register an endpoint

Send a JSON body with the target url. name (defaults to "API webhook"), description and events are optional. An empty or omitted events list subscribes the endpoint to all event types; otherwise pass a subset of the subscribable events.

Example Request

curl -X POST "https://app.holofin.ai/api/v1.1/webhooks" \
  -H "X-API-Key: $HOLOFIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/webhooks/holofin",
    "name": "Project Falcon",
    "events": ["workflow-run.completed", "workflow-run.failed"]
  }'

Response (201 Created)

{
  "id": "whep_a1b2c3d4e5f6g7h8",
  "name": "Project Falcon",
  "url": "https://your-app.example.com/webhooks/holofin",
  "events": ["workflow-run.completed", "workflow-run.failed"],
  "status": "active",
  "description": "",
  "created_at": "2026-07-22T14:30:00Z",
  "secret": "b7f3...store-this-it-is-only-returned-once"
}

Use the returned secret to verify the X-Holofin-Signature header on incoming deliveries (see Webhook Security). On subsequent GET responses the secret field is omitted.

Update, disable or delete

PATCH accepts any subset of url, name, description, events and status. Set "status": "disabled" to pause deliveries without deleting the endpoint, and "status": "active" to resume (which also clears the failure streak). DELETE returns 204 No Content.

Disable an endpoint

curl -X PATCH "https://app.holofin.ai/api/v1.1/webhooks/whep_a1b2c3d4e5f6g7h8" \
  -H "X-API-Key: $HOLOFIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "disabled"}'

Errors are returned as {"error": {"code": "...", "message": "..."}} with codes such as MISSING_URL, INVALID_URL, INVALID_EVENTS, INVALID_STATUS, WEBHOOK_LIMIT_REACHED (HTTP 409) and NOT_FOUND (HTTP 404).

Event Types

Workflow events notify you about the status of document processing workflows.

EventDescription
workflow-run.completedWorkflow has completed successfully. All nodes have finished processing.
workflow-run.failedWorkflow has failed due to an error. The payload includes the error message.
workflow-run.pausedWorkflow is paused and waiting for human approval (e.g., at a Human Review node). The payload includes the pause reason and which node triggered the pause.
workflow-run.confirmedExtraction has been confirmed (validated) by a user. Fired once, when the user first validates. The payload includes the full extraction data, the user who confirmed, and an optional comment.
workflow-run.republishedAn already-confirmed extraction has been re-pushed upstream by a user (e.g. after edits, or to recover from a downstream consumer that missed the original workflow-run.confirmed). The payload mirrors workflow-run.confirmed and additionally includes the original confirmation metadata.
node-run.completedIndividual agent node has completed. The payload includes the node result data specific to the node type (classifier, segmenter, extractor, humanReview, validator).
node-run.failedIndividual agent node has failed. The payload includes the error message.

Dossier events notify you when a dossier changes state as documents and values arrive. Like every other event, they are delivered only to the endpoints subscribed to them.

EventDescription
case.updatedThe dossier's status or coherence changed: a document was processed, a value was pushed, a rule verdict moved. The payload carries the full dossier projection (see Dossier Event Payload).
case.completedThe dossier transitioned to complete. Fired in addition to the case.updated for that same change, so subscribe to it alone when you only care about the finish line.

Webhook Payload

All webhook payloads follow a consistent structure:

Webhook Payload Structure

{
  "id": "whev_abc123def456",
  "event": "workflow-run.completed",
  "created_at": "2025-06-02T14:30:00Z",
  "data": {
    "object": {
      "run_id": "wr_xyz789ghi012",
      "workflow_id": "wf_abc123def456",
      "workflow_name": "Bank Statement Processing",
      "document_id": "doc_abc123def456",
      "status": "completed",
      "validation_status": "OK",
      "no_route_matched": false,
      "unmatched_classifications": null,
      "timestamp": "2025-06-02T14:30:00Z"
    }
  }
}

For workflow-run.completed events, the payload includes:

  • validation_status - Composite status reflecting both consistency-check outcome and manual review state, in priority order:
    • "VALIDATED": a reviewer has confirmed the extraction
    • "INVALIDATED": a reviewer has reset / rejected a previous confirmation
    • "PASSED": consistency checks passed, no manual review yet
    • "WARNING": consistency checks raised warnings, no manual review yet
    • "ERROR": consistency checks raised errors, no manual review yet
    • "NO_VALIDATION": no validators were configured
    • null: no extraction aggregator (e.g., classification-only workflow)
    The consistency portion is the worse of the extraction and business outcomes, so this field keeps its existing meaning.
  • no_route_matched - true when the run completed without any extraction because the detected document type had no matching route in the workflow (a branching step fell through to its "all others" path and no extractor ran). The workflow itself ran without error; its routing does not handle the document's type.
  • unmatched_classifications - When no_route_matched is true, the detected classification(s) or segment type(s) that had no route (e.g. ["carte-nationale-didentit"]). null otherwise.

For workflow-run.paused events, the payload includes additional context about why the workflow paused:

  • pause_reason - Why the workflow paused (e.g., "approval" for human review)
  • paused_at_node - The node ID where the workflow is waiting

For workflow-run.failed events, the payload includes:

  • error - Error message describing what went wrong

For workflow-run.confirmed events, the payload includes:

  • confirmed_by - Email of the user who confirmed the extraction
  • confirmed_at - ISO 8601 UTC timestamp when the extraction was confirmed
  • comment - Optional comment provided during confirmation
  • extraction_data - Full extraction data (same format as extraction.processed payload). Includes the per-part checks rule inventory, so payload size grows with the number of checks a document type runs (a fully validated financial statement carries ~40 entries per part). Also carries a run-level document_pdf_url field (the original document PDF) and a pdf_url field on every document_parts entry (that part's pages as one PDF); fetching either requires the X-API-Key header, and ?format=json on them yields a time-limited direct link.

For workflow-run.republished events, the payload includes:

  • status - Always "republished"
  • republished_by - Email of the user who triggered the republish
  • republished_at - ISO 8601 UTC timestamp of the republish action
  • originally_confirmed_by - Email of the user who first confirmed the extraction (may be null if the original reviewer's account no longer exists)
  • originally_confirmed_at - ISO 8601 UTC timestamp of the original confirmation (may be null)
  • comment - Optional comment provided when republishing
  • extraction_data - Full extraction data (same format as workflow-run.confirmed, including document_pdf_url and per-part pdf_url, both requiring the X-API-Key header to fetch); typically reflects edits applied since the original confirmation

Use republished_at together with originally_confirmed_at to detect re-publishes and decide whether to upsert or version downstream records. Republishes can fire multiple times for the same run.

Node Event Payload

Node events (node-run.completed, node-run.failed) provide granular updates as each workflow node executes. For node-run.completed events, the payload includes a result field with node-specific output data:

Node Completed Payload (Classifier Example)

{
  "id": "whev_abc123def456",
  "event": "node-run.completed",
  "created_at": "2025-06-02T14:30:00Z",
  "data": {
    "object": {
      "run_id": "wr_xyz789ghi012",
      "workflow_id": "wf_abc123def456",
      "document_id": "doc_abc123def456",
      "node_id": "classifier_1",
      "node_type": "classifier",
      "status": "COMPLETED",
      "timestamp": "2025-06-02T14:30:00Z",
      "result": {
        "classification": "bank_statement",
        "confidence": 0.95,
        "reasoning": "Document contains bank account headers..."
      }
    }
  }
}

The result field structure varies by node type:

Node TypeResult Fields
classifierclassification, confidence, all_classes, reasoning
segmentersegments[], segment_count
extractoraggregator_id, extraction status (full data via extraction aggregator API)
fraudAnalyzeranalysis_id, risk_level, risk_score, signal_count, categories_triggered, signal_summary (details)
humanReviewapproved, approval data
validatorValidation results

For node-run.failed events, the payload includes:

  • error - Error message describing what went wrong in the node

Dossier Event Payload

Dossier events (case.updated, case.completed) carry the dossier itself: data.object is the same projection that GET /api/v1.1/cases/{case_id} answers with. The field-by-field reference lives on the Dossiers page and is not duplicated here. Three envelope keys ride along inside the object:

  • event - The event type, repeated inside the object
  • previous_status - The case_status before this change, lowered like case_status itself, or null when there was none
  • timestamp - ISO 8601 UTC timestamp of the evaluation that fired the event

Dossier Completed Payload

{
  "id": "whev_abc123def456",
  "event": "case.completed",
  "created_at": "2026-08-10T08:04:08Z",
  "data": {
    "object": {
      "case_id": "case_9f3k2m81xq7c",
      "config_id": "6dc962d3-14df-4d41-b858-75f2b24b648f",
      "config_name": "Crédit professionnel",
      "case_status": "complete",
      "decision": "none",
      "coherence": "ok",
      "created_at": "2026-08-10T07:59:02Z",
      "evaluated_at": "2026-08-10T08:04:07.804604Z",
      "fields": {
        "montant": {"label": "Montant du prêt", "value": 250000, "state": "provided", "required": true}
      },
      "requirements": [
        {"key": "releve_bancaire", "label": "Bank statement",
         "state": "satisfied", "min": 2, "max": null, "provided": 2,
         "accepted_categories": ["releve_bancaire"],
         "conditional": false, "activation": null,
         "documents": [
           {"document_id": "doc_k2VgTmN4pQ7cWxYz", "part_key": "",
            "state": "processed", "name": "releve_juin.pdf",
            "run_id": "wr_3fW7kZq9YtM5cHx2",
            "category": "releve_bancaire", "pages": [1], "owners": []},
           {"document_id": "doc_8fRsLdH6wB3nJvQa", "part_key": "",
            "state": "processed", "name": "releve_juillet.pdf",
            "run_id": "wr_6bN4pDs8RvK1mTy3",
            "category": "releve_bancaire", "pages": [1], "owners": []}
         ]}
      ],
      "participants": {},
      "rules": [
        {"code": "case_rule_a3f19c04d2", "label": "Capital social suffisant",
         "kind": "consistency", "severity": "blocking", "status": "ok", "reason": null,
         "depends_on": [
           {"type": "requirement", "key": "kbis", "party_ref": null, "state": "ready"},
           {"type": "dossier", "key": "dossier", "party_ref": null, "state": "ready"}],
         "documents": [],
         "values": [{"field": "kbis.capital", "value": 50000,
                     "document_id": "doc_k2VgTmN4pQ7cWxYz"},
                    {"field": "dossier.montant", "value": 25000}],
         "provisional": false,
         "rule": "ASSERT @case.documents.kbis.capital >= @case.montant",
         "description": null}
      ],
      "unassigned_documents": [],
      "event": "case.completed",
      "previous_status": "pending_review",
      "timestamp": "2026-08-10T08:04:08Z"
    }
  }
}

Dossier events are organization-level, not run-level: a per-run override URL never receives them, and they always go to your registered endpoints, honoring each endpoint's event subscription. When a dossier transitions to complete, both events fire for that same change with distinct event ids.

Envelope Fields

FieldTypeDescription
idstringUnique identifier for this webhook event (prefix: whev_)
eventstringType of event that occurred
created_atstringISO 8601 UTC timestamp when the event was created
data.objectobjectEvent-specific data payload (see below)

Data Object Fields

FieldTypeDescription
run_idstringWorkflow run identifier (prefix: wr_)
workflow_idstringWorkflow definition identifier (prefix: wf_)
workflow_namestringHuman-readable workflow name
document_idstringDocument identifier (prefix: doc_)
statusstringWorkflow status: completed, failed, or paused
validation_statusstring | nullComposite validation result (completed only): VALIDATED, INVALIDATED, PASSED, WARNING, ERROR, NO_VALIDATION, or null. See the list above for the full priority order.
timestampstringISO 8601 UTC timestamp when the status changed
errorstringError message (failed events only)
pause_reasonstringWhy the workflow paused (paused events only)
paused_at_nodestringNode where workflow is waiting (paused events only)
confirmed_bystringEmail of the user who confirmed (confirmed events only)
confirmed_atstringISO 8601 UTC timestamp of confirmation (confirmed events only)
commentstring | nullOptional comment provided during confirmation (confirmed events only)
extraction_dataobject | nullFull extraction data including all segments (confirmed events only). Carries the per-part checks rule inventory; payload size grows with the number of checks the document type runs.

Webhook Security

Holofin signs every webhook request with HMAC so you can check that it came from Holofin and was not modified.

Signature Verification

Each webhook request includes a signature in the X-Holofin-Signature header. This signature is generated using HMAC-SHA256 with your webhook secret.

Python Example

import hmac
import hashlib

def verify_webhook_signature(payload, signature, secret):
    """
    Verify the webhook signature to ensure authenticity.

    Args:
        payload: The raw request body (bytes)
        signature: The signature from X-Holofin-Signature header
        secret: Your webhook secret

    Returns:
        bool: True if signature is valid
    """
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        payload,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected_signature, signature)

# In your webhook handler:
@app.route('/webhook', methods=['POST'])
def handle_webhook():
    payload = request.get_data()
    signature = request.headers.get('X-Holofin-Signature')

    if not verify_webhook_signature(payload, signature, WEBHOOK_SECRET):
        return 'Invalid signature', 401

    # Process the webhook...
    return 'OK', 200

Retries & Reliability

Failed deliveries are retried automatically:

  • Success criteria: Any 2xx HTTP status code is considered successful
  • Retry schedule: Failed webhooks are retried with exponential backoff plus jitter to prevent thundering herd
  • Max attempts: Up to 7 retry attempts over 48 hours
  • Status change: Endpoints marked as "FAILING" after 5 consecutive failures (but continue receiving events)

Retry Schedule

AttemptBase DelayJitter RangeCumulative Time
1Immediate-0 seconds
21 minute+ 0-30 seconds~1 minute
35 minutes+ 0-60 seconds~6 minutes
430 minutes+ 0-2 minutes~36 minutes
52 hours+ 0-5 minutes~2.5 hours
66 hours+ 0-10 minutes~8.5 hours
712 hours+ 0-15 minutes~20.5 hours
824 hours+ 0-30 minutes~45 hours
Note: Jitter is added to each retry to prevent multiple webhooks from retrying at the exact same time, reducing server load.

Best Practices

  • Always verify signatures
    Validate the HMAC signature on every webhook to ensure requests are from Holofin.
  • Handle events idempotently
    Use the event ID to ensure you process each event only once, even if it's delivered multiple times.
  • Respond quickly
    Return a 2xx status code within 30 seconds. Process the event asynchronously if your handler needs more time.
  • Monitor failures
    Check your webhook logs regularly to identify and fix any delivery issues.

Testing Webhooks

You can test your webhook integration using the test button in the webhook settings.