Skip to main content

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.
1

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.
2

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.
3

Add headers (optional)

Add custom HTTP Headers for example for authentication or routing.
4

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.

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.
{
  "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"
  }
}
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.

Downloading the file (optional)

The webhook payload does not include the original file. If you need it, please refer to the API reference.

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)
Reject requests where X-Cradl-Timestamp is too old (e.g., >5 minutes) or where a previously seen X-Cradl-Nonce is reused

Examples

The code examples below have been generated by an AI. Use them as a starting point and verify correctness before using in production.
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)
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);
});
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) }
}
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();
  }
}
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()
  }
}
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;
}

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