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.
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.
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.
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.
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
Given a shared signing secret, the signature is computed on the concatenation of these exact byte sequences:
HTTP method (e.g., POST), as bytes
Full request URL (including query string), as bytes
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).
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
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, hashlibfrom flask import requestdef 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 sentapp.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 mainimport ( "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.*;@RestControllerpublic 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.ResponseEntityimport org.springframework.web.bind.annotation.*import jakarta.servlet.http.HttpServletRequestimport javax.crypto.Macimport javax.crypto.spec.SecretKeySpecimport java.nio.charset.StandardCharsetsimport java.security.MessageDigestimport java.util.*@RestControllerclass 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;}