> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ingestly.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks and callbacks

> Receive pipeline results via webhook callbacks.

When your pipeline includes a **callback output** node, Ingestly sends the processing results to your webhook URL when a run completes.

## Setting up callbacks

1. Add a **Callback output** node to your pipeline
2. Configure the **URL** where you want to receive results
3. Optionally add custom **headers** (for example, an authorization token)
4. Save and activate the pipeline

## Payload format

Ingestly sends a `POST` request to your callback URL with a JSON payload containing the run results:

```json theme={null}
{
  "runId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "pipelineId": "7fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "Completed",
  "documentName": "invoice.pdf",
  "data": {
    "vendor_name": "Acme Corp",
    "invoice_number": "INV-2024-001",
    "total_amount": 1250.00,
    "currency": "USD",
    "line_items": [
      {
        "description": "Widget A",
        "quantity": 10,
        "unit_price": 100.00,
        "amount": 1000.00
      },
      {
        "description": "Widget B",
        "quantity": 5,
        "unit_price": 50.00,
        "amount": 250.00
      }
    ]
  },
  "completedAt": "2024-01-15T10:30:00Z"
}
```

The `data` field contains the extracted data matching your pipeline's output schema.

## Webhook signing verification

To verify that webhook requests are genuinely from Ingestly, you can use webhook signing keys.

1. [Generate a webhook signing key](/admin/webhook-signing-keys) in your organization settings
2. Ingestly includes a signature in the request headers
3. Verify the signature in your application before processing the payload

### Verifying the signature

The `X-Ingestly-Signature` header contains a timestamp and one or more signatures in the format `t=timestamp,v1=signature`. During [key rotation](/admin/webhook-signing-keys), the header includes multiple `v1=` signatures, one for each active key. To verify:

1. Parse the header to extract the timestamp (`t=`) and signature(s) (`v1=`)
2. Reconstruct the signed payload as `{timestamp}.{raw_body}`
3. Compute an HMAC-SHA256 hash using your signing key
4. Compare the result against each signature using a timing-safe comparison

<Warning>You must use the **raw request body** bytes for verification, not a parsed or re-serialized object. If your framework parses the body (e.g., as JSON), the re-serialized output may differ from the original payload and verification will fail. Read the raw body before any middleware processes it.</Warning>

```javascript theme={null}
const crypto = require("crypto");

// Express: use express.raw() middleware to get the raw body
// app.post("/webhook", express.raw({ type: "application/json" }), handler)

function verifyWebhookSignature(signatureHeader, rawBody, secrets) {
  const parts = signatureHeader.split(",");
  const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2);
  const signatures = parts
    .filter((p) => p.startsWith("v1="))
    .map((p) => p.slice(3));

  if (!timestamp || signatures.length === 0) return false;

  // Protect against replay attacks (5 min tolerance)
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (Math.abs(age) > 300) return false;

  const signedPayload = `${timestamp}.${rawBody}`;

  // Try each secret (supports key rotation)
  for (const secret of secrets) {
    const expected = crypto
      .createHmac("sha256", secret)
      .update(signedPayload)
      .digest("hex");

    const expectedBuf = Buffer.from(expected, "hex");
    const match = signatures.some((sig) => {
      const sigBuf = Buffer.from(sig, "hex");
      return (
        sigBuf.length === expectedBuf.length &&
        crypto.timingSafeEqual(sigBuf, expectedBuf)
      );
    });

    if (match) return true;
  }
  return false;
}
```

```python theme={null}
import hmac
import hashlib
import time

# Flask: use request.get_data() to get the raw body
# Django: use request.body

def verify_webhook_signature(signature_header, raw_body, secrets):
    parts = signature_header.split(",")
    timestamp = None
    signatures = []
    for part in parts:
        if part.startswith("t="):
            timestamp = part[2:]
        elif part.startswith("v1="):
            signatures.append(part[3:])

    if not timestamp or not signatures:
        return False

    # Protect against replay attacks (5 min tolerance)
    age = int(time.time()) - int(timestamp)
    if abs(age) > 300:
        return False

    signed_payload = f"{timestamp}.{raw_body}"

    # Try each secret (supports key rotation)
    for secret in secrets:
        expected = hmac.new(
            secret.encode("utf-8"),
            signed_payload.encode("utf-8"),
            hashlib.sha256
        ).hexdigest()

        if any(hmac.compare_digest(expected, sig) for sig in signatures):
            return True
    return False
```

## Retry behavior

If your webhook endpoint returns a non-2xx status code, Ingestly retries the delivery:

* Up to **3 retries** with exponential backoff
* Retry intervals: 1 minute, 5 minutes, 30 minutes
* After all retries fail, the callback is marked as failed (the run itself is not affected)

## Testing locally

To test webhooks during development, use a tunnel service to expose your local server:

1. Start your local webhook endpoint
2. Use a tunneling tool to get a public URL
3. Configure the callback output with the public URL
4. Upload a test document to trigger the pipeline

<Warning>Use your production webhook signing key verification in development too, to catch integration issues early.</Warning>
