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

# Webhook

> Use webhooks to export data from Cradl AI.

export const Image = ({style, ...rest}) => {
  let sizeStyle = {
    width: '75%'
  };
  return <Frame><img {...rest} style={{
    ...style,
    ...sizeStyle
  }} /></Frame>;
};

## Introduction

Webhooks let you send structured results from your Cradl AI agent to any HTTP endpoint. Use this to push data into your own service or systems like n8n, Make, or custom APIs.

## Before you begin

* Have an HTTPS endpoint that accepts JSON requests
* Optionally prepare a request inspector (e.g., your own dev endpoint) to verify the payload
* Upload a test document to your agent so there’s data to send

## Configure your webhook

The steps below add the webhook export in Cradl AI and verify that your endpoint receives the payload.

<Steps>
  <Step title="Add a Webhook export">
    In your agent, add a new export and select **Webhook**. You’ll see fields for the **URL**, **HTTP Method**, and optional **HTTP Headers**.

    <Image src="/images/webhook-step1.webp" alt="Webhook settings in Cradl AI" />
  </Step>

  <Step title="Define URL and method">
    Enter your endpoint in **URL** and choose the **HTTP Method** your service expects (POST is common). Cradl AI sends a JSON body with the extracted data and run context.
  </Step>

  <Step title="Add headers (optional)">
    Add custom **HTTP Headers** for example for authentication or routing.
  </Step>

  <Step title="Test the webhook">
    Click **Test webhook** to send a sample payload to your URL. Confirm that your endpoint receives the request and returns a **2xx** response.
  </Step>
</Steps>

## Payload format

Your endpoint receives a JSON body similar to the example below. The `output` section contains field values organized by group and field IDs. Each field entry includes the recognized `value`, original `rawValue`, `confidence`, and `page`. The `context` section includes references like `runId` and `documentId`.

```json theme={null}
{
  "output": {
    "n2379dpkjj": [
      {
        "d6xz8w5wnr": {
          "rawValue": "The Meat",
          "value": "The Meat",
          "confidence": 0.926,
          "page": 0,
          "name": "Merchant"
        },
        "2i1sspmmxw": {
          "rawValue": "2017-05-23",
          "value": "2017-05-23",
          "confidence": 0.945,
          "page": 0,
          "name": "Purchase Date"
        },
        "a8b40ix8k9": {
          "rawValue": "138,00",
          "value": "138",
          "confidence": 0.982,
          "page": 0,
          "name": "Total Amount"
        },
        "s5otx28oyk": {
          "rawValue": "SEK",
          "value": "SEK",
          "confidence": 0.244,
          "page": 0,
          "name": "Currency"
        }
      }
    ]
  },
  "context": {
    "runId": "cradl:run:eee4d54340cfe1071a98735234a66755",
    "documentId": "cradl:document:349e99a0d98046db8219b70393a3289f"
  }
}
```

<Info>
  Field and group IDs are stable identifiers. The human-friendly field name is available under `name` for convenience.
  Prefer the normalized `value` when mapping, and fall back to `rawValue` if needed.
</Info>

## Downloading the file (optional)

The webhook payload does not include the original file. If you need it, please refer to the [API reference](/api-reference/introduction).

## Verifying signatures

Each webhook request is signed with an HMAC SHA‑256 signature so you can verify authenticity and protect against replay attacks. The following headers are automatically added by Cradl AI:

* `X-Cradl-Timestamp` — Unix timestamp (seconds) when the request was created
* `X-Cradl-Nonce` — A random UUIDv4 (hex, no dashes)
* `X-Cradl-Signature` — Hex-encoded HMAC SHA‑256 over the message described below

#### How the signature is computed

Given a shared signing secret, the signature is computed on the concatenation of these exact byte sequences:

1. HTTP method (e.g., `POST`), as bytes
2. Full request URL (including query string), as bytes
3. All HTTP headers except `X-Cradl-Signature`, sorted by header name and concatenated with no delimiter as `key:value` pairs, then concatenated together (still no delimiter).
4. Raw request body bytes (the JSON payload)

<Info>
  Reject requests where `X-Cradl-Timestamp` is too old (e.g., >5 minutes) or where a previously seen `X-Cradl-Nonce` is reused
</Info>

#### Examples

<Warning>The code examples below have been generated by an AI. Use them as a starting point and **verify correctness** before using in production.</Warning>

<CodeGroup>
  ```python Python theme={null}
  import hmac, hashlib
  from flask import request

  def verify_cradl_webhook(signing_secret: str) -> bool:
      provided = request.headers.get('X-Cradl-Signature', '')
      method = request.method.upper()
      url = request.url  # full URL including query

      headers = [(k, v) for k, v in request.headers.items()
                 if k.lower() != 'x-cradl-signature']
      headers.sort(key=lambda kv: kv[0])
      header_blob = ''.join(f'{k}:{v}' for k, v in headers)

      body = request.get_data(cache=False, as_text=False)  # raw bytes
      message = method.encode() + url.encode() + header_blob.encode() + body

      expected = hmac.new(signing_secret.encode(), message, hashlib.sha256).hexdigest()
      return hmac.compare_digest(provided, expected)
  ```

  ```javascript JavaScript theme={null}
  import crypto from 'crypto';
  import express from 'express';

  const app = express();

  // Capture raw body so we can verify exactly what was sent
  app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
    const signingSecret = process.env.CRADL_SIGNING_SECRET;
    const provided = req.get('X-Cradl-Signature') || '';

    // Full URL exactly as received
    const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;

    // Rebuild headers from rawHeaders to preserve original casing
    const pairs = [];
    for (let i = 0; i < req.rawHeaders.length; i += 2) {
      const name = req.rawHeaders[i];
      const value = req.rawHeaders[i + 1];
      if (name.toLowerCase() === 'x-cradl-signature') continue;
      pairs.push([name, value]);
    }
    pairs.sort((a, b) => a[0].localeCompare(b[0]));
    const headerBlob = pairs.map(([k, v]) => `${k}:${v}`).join('');

    const method = req.method.toUpperCase();
    const chunks = [method, url, headerBlob].map(String).map(Buffer.from);
    chunks.push(Buffer.from(req.body)); // raw bytes
    const message = Buffer.concat(chunks);

    const expectedHex = crypto.createHmac('sha256', signingSecret)
      .update(message)
      .digest('hex');

    // timing‑safe compare of hex strings
    const toHexBuf = (s) => Buffer.from(String(s), 'hex');
    let ok = false;
    try {
      const a = toHexBuf(provided);
      const b = toHexBuf(expectedHex);
      ok = a.length === b.length && crypto.timingSafeEqual(a, b);
    } catch (_) {
      ok = false;
    }
    res.sendStatus(ok ? 204 : 401);
  });
  ```

  ```go Go theme={null}
  package main

  import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "os"
    "sort"
    "strings"
  )

  func verifyCradlWebhook(r *http.Request, secret string, body []byte) bool {
    provided := r.Header.Get("X-Cradl-Signature")

    scheme := "http"
    if r.TLS != nil { scheme = "https" }
    if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" { scheme = xf }
    url := fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.RequestURI())

    names := make([]string, 0, len(r.Header))
    for k := range r.Header {
      if strings.EqualFold(k, "X-Cradl-Signature") { continue }
      names = append(names, k)
    }
    sort.Slice(names, func(i, j int) bool { return strings.ToLower(names[i]) < strings.ToLower(names[j]) })

    var headerBlob strings.Builder
    for _, n := range names {
      headerBlob.WriteString(n)
      headerBlob.WriteString(":")
      headerBlob.WriteString(strings.Join(r.Header[n], ","))
    }

    msg := []byte(r.Method)
    msg = append(msg, []byte(url)...)
    msg = append(msg, []byte(headerBlob.String())...)
    msg = append(msg, body...)

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(msg)
    expected := mac.Sum(nil)

    prov, err := hex.DecodeString(provided)
    if err != nil { return false }
    return len(prov) == len(expected) && subtle.ConstantTimeCompare(prov, expected) == 1
  }

  func handler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    ok := verifyCradlWebhook(r, os.Getenv("CRADL_SIGNING_SECRET"), body)
    if ok { w.WriteHeader(http.StatusNoContent) } else { w.WriteHeader(http.StatusUnauthorized) }
  }
  ```

  ```java Java theme={null}
  import org.springframework.http.ResponseEntity;
  import org.springframework.web.bind.annotation.*;
  import jakarta.servlet.http.HttpServletRequest;

  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import java.nio.charset.StandardCharsets;
  import java.security.MessageDigest;
  import java.util.*;

  @RestController
  public class WebhookController {
    private final String signingSecret = System.getenv("CRADL_SIGNING_SECRET");

    @PostMapping("/webhook")
    public ResponseEntity<Void> webhook(HttpServletRequest req, @RequestBody byte[] body) throws Exception {
      String provided = Optional.ofNullable(req.getHeader("X-Cradl-Signature")).orElse("");

      String method = req.getMethod();
      String url = req.getRequestURL().toString();
      if (req.getQueryString() != null) url += "?" + req.getQueryString();

      List<String> names = Collections.list(req.getHeaderNames());
      names.removeIf(n -> n.equalsIgnoreCase("X-Cradl-Signature"));
      names.sort(String.CASE_INSENSITIVE_ORDER);

      StringBuilder headerBlob = new StringBuilder();
      for (String name : names) {
        List<String> values = Collections.list(req.getHeaders(name));
        headerBlob.append(name).append(":").append(String.join(",", values));
      }

      byte[] message = concat(
        method.getBytes(StandardCharsets.UTF_8),
        url.getBytes(StandardCharsets.UTF_8),
        headerBlob.toString().getBytes(StandardCharsets.UTF_8),
        body
      );

      Mac mac = Mac.getInstance("HmacSHA256");
      mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
      byte[] expected = mac.doFinal(message);
      String expectedHex = bytesToHex(expected);

      boolean ok = provided.length() == expectedHex.length() &&
        MessageDigest.isEqual(provided.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8),
                              expectedHex.getBytes(StandardCharsets.UTF_8));

      return ok ? ResponseEntity.noContent().build() : ResponseEntity.status(401).build();
    }

    private static byte[] concat(byte[]... arrays) {
      int len = 0; for (byte[] a : arrays) len += a.length;
      byte[] out = new byte[len]; int pos = 0;
      for (byte[] a : arrays) { System.arraycopy(a, 0, out, pos, a.length); pos += a.length; }
      return out;
    }

    private static String bytesToHex(byte[] bytes) {
      StringBuilder sb = new StringBuilder(bytes.length * 2);
      for (byte b : bytes) sb.append(String.format("%02x", b));
      return sb.toString();
    }
  }
  ```

  ```kotlin Kotlin theme={null}
  import org.springframework.http.ResponseEntity
  import org.springframework.web.bind.annotation.*
  import jakarta.servlet.http.HttpServletRequest
  import javax.crypto.Mac
  import javax.crypto.spec.SecretKeySpec
  import java.nio.charset.StandardCharsets
  import java.security.MessageDigest
  import java.util.*

  @RestController
  class WebhookController {
    private val signingSecret: String = System.getenv("CRADL_SIGNING_SECRET") ?: ""

    @PostMapping("/webhook")
    fun webhook(req: HttpServletRequest, @RequestBody body: ByteArray): ResponseEntity<Void> {
      val provided = req.getHeader("X-Cradl-Signature") ?: ""
      val method = req.method
      var url = req.requestURL.toString()
      req.queryString?.let { url += "?" + it }

      val names = Collections.list(req.headerNames)
        .filterNot { it.equals("X-Cradl-Signature", ignoreCase = true) }
        .sortedBy { it.lowercase() }

      val headerBlob = buildString {
        for (name in names) {
          val value = Collections.list(req.getHeaders(name)).joinToString(",")
          append(name).append(":").append(value)
        }
      }

      val message = method.toByteArray() +
        url.toByteArray() +
        headerBlob.toByteArray() +
        body

      val mac = Mac.getInstance("HmacSHA256")
      mac.init(SecretKeySpec(signingSecret.toByteArray(), "HmacSHA256"))
      val expectedHex = mac.doFinal(message).joinToString("") { "%02x".format(it) }

      val ok = provided.length == expectedHex.length &&
        MessageDigest.isEqual(provided.lowercase().toByteArray(), expectedHex.toByteArray())

      return if (ok) ResponseEntity.noContent().build() else ResponseEntity.status(401).build()
    }
  }
  ```

  ```csharp C# theme={null}
  using System.Security.Cryptography;
  using System.Text;
  using Microsoft.AspNetCore.Builder;
  using Microsoft.AspNetCore.Http;

  var builder = WebApplication.CreateBuilder(args);
  var app = builder.Build();

  app.MapPost("/webhook", async (HttpRequest req) => {
    var provided = req.Headers["X-Cradl-Signature"].ToString() ?? string.Empty;

    req.EnableBuffering();
    using var ms = new MemoryStream();
    await req.Body.CopyToAsync(ms);
    var body = ms.ToArray();
    req.Body.Position = 0;

    var method = req.Method.ToUpperInvariant();
    var url = $"{req.Scheme}://{req.Host}{req.Path}{req.QueryString}";

    var names = req.Headers
      .Where(h => !string.Equals(h.Key, "X-Cradl-Signature", StringComparison.OrdinalIgnoreCase))
      .Select(h => h.Key)
      .OrderBy(k => k, StringComparer.OrdinalIgnoreCase)
      .ToList();

    var headerBlob = new StringBuilder();
    foreach (var name in names) {
      var value = string.Join(",", req.Headers[name].ToArray());
      headerBlob.Append(name).Append(":").Append(value);
    }

    byte[] message = Combine(
      Encoding.UTF8.GetBytes(method),
      Encoding.UTF8.GetBytes(url),
      Encoding.UTF8.GetBytes(headerBlob.ToString()),
      body
    );

    var secret = Environment.GetEnvironmentVariable("CRADL_SIGNING_SECRET") ?? string.Empty;
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expectedHex = Convert.ToHexString(hmac.ComputeHash(message)).ToLowerInvariant();

    var ok = CryptographicOperations.FixedTimeEquals(
      Encoding.UTF8.GetBytes(provided.ToLowerInvariant()),
      Encoding.UTF8.GetBytes(expectedHex)
    );

    return Results.StatusCode(ok ? 204 : 401);
  });

  app.Run();

  static byte[] Combine(params byte[][] arrays) {
    var len = arrays.Sum(a => a.Length);
    var rv = new byte[len];
    var pos = 0;
    foreach (var a in arrays) { Buffer.BlockCopy(a, 0, rv, pos, a.Length); pos += a.Length; }
    return rv;
  }
  ```
</CodeGroup>

## Best practices

* Return a fast **2xx** response after receiving the payload; perform longer processing asynchronously if possible
* Use a shared secret or token in headers to authenticate requests from Cradl AI
* Log the entire body during development to validate field mappings

## Troubleshooting

* No requests arriving: Verify the **URL** is publicly reachable and click **Test webhook**
* 401/403 from your service: Check header names/values and tokens
* Missing fields: Upload and validate a fresh document, then test again to refresh available data
* Cannot download file: Include the `Authorization: Bearer <ACCESS_TOKEN>` header when requesting `fileUrl`
* If you're using a proxy or gateway, ensure it doesn't modify headers or the request body
