Webhooks
Configure LeapOCR webhooks to receive signed job lifecycle events in real time
Webhooks
LeapOCR webhooks let your application react to OCR jobs as they move through the pipeline. When something important happens, such as a job completing or failing, LeapOCR sends a signed POST request to the endpoint you configured in the dashboard.
Webhooks are available on Starter, Growth, Scale, and Enterprise plans.
Events
You can subscribe to these event types:
| Event | When it fires |
|---|---|
job.created | A new OCR job is created |
job.started | Processing begins |
job.completed | Processing finishes successfully |
job.failed | Processing ends with an error |
Each subscription can include up to 10 event selections. Your endpoint must use HTTPS. In production, LeapOCR rejects localhost, loopback, private-network IPs, and direct IP addresses.
Set Up in the Dashboard
Creating a webhook in LeapOCR is straightforward:
Open Webhooks
Open the Webhooks section in your dashboard.
Create a subscription
Add a public HTTPS endpoint, choose the events you want to receive, and optionally add a description.
Save the secret immediately
When the webhook is created, copy the signing secret and store it securely. You will use it to verify incoming requests.
Test before going live
Before you rely on it in production, send a test event from the dashboard and confirm that your endpoint accepts and verifies the request.
What LeapOCR Sends
Webhook deliveries are sent as JSON POST requests with these headers:
Content-Type: application/jsonX-R2-Signature: <hex hmac digest>User-Agent: LeapOCR-Webhook/1.0
The body contains event metadata plus the job fields that are relevant for that stage of processing.
Example job.completed payload
{
"event_type": "job.completed",
"event_id": "b2837c9d-3356-4d6e-b31c-1d931fb8e6ec",
"timestamp": "2026-03-19T10:21:44Z",
"job_id": "6d3e2a79-8f80-4d71-9ab9-b8410d14ed1d",
"user_id": "user_123",
"team_id": "team_456",
"organization_id": "org_123",
"status": "completed",
"file_name": "invoice.pdf",
"file_size": 483920,
"total_pages": 3,
"processed_pages": 3,
"created_at": "2026-03-19T10:21:10Z",
"completed_at": "2026-03-19T10:21:44Z",
"success_pages": 3,
"empty_pages": 0,
"total_tokens": 1842,
"credits_used": 3
}Example job.failed payload
{
"event_type": "job.failed",
"event_id": "6dc45bf5-2c8f-4cbc-b7b6-1015d3e64c2e",
"timestamp": "2026-03-19T10:28:12Z",
"job_id": "6d3e2a79-8f80-4d71-9ab9-b8410d14ed1d",
"user_id": "user_123",
"team_id": "team_456",
"organization_id": "org_123",
"status": "failed",
"file_name": "invoice.pdf",
"file_size": 483920,
"total_pages": 3,
"processed_pages": 1,
"created_at": "2026-03-19T10:21:10Z",
"error_message": "Document processing failed"
}Example: Use job.completed to fetch the result
One useful pattern is to let the webhook tell you when the job is ready, then use the SDK to load the full result.
- Receive the signed webhook.
- Verify the signature.
- Read
job_idfrom the payload. - Use the SDK to fetch the full OCR result for that job.
Example in Node.js with the JavaScript SDK:
import express from "express";
import { LeapOCR } from "leapocr";
const app = express();
const leapocr = new LeapOCR({
apiKey: process.env.LEAPOCR_API_KEY,
});
app.post(
"/leapocr/webhooks",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.header("X-R2-Signature");
const secret = process.env.LEAPOCR_WEBHOOK_SECRET;
if (!signature || !secret) {
res.status(400).send("Missing signature headers");
return;
}
const rawBody = req.body.toString("utf8");
if (!(await LeapOCR.verifyWebhookSignature(rawBody, signature, secret))) {
res.status(401).send("Invalid signature");
return;
}
const event = JSON.parse(rawBody);
if (event.event_type === "job.completed" && event.job_id) {
const result = await leapocr.ocr.getJobResult(event.job_id);
console.log("Completed job:", event.job_id);
console.log("Credits used:", result.credits_used);
console.log("Pages:", result.total_pages);
console.log("First page result:", result.pages?.[0]?.result);
// Persist the result, trigger a workflow, or notify your users here.
}
if (event.event_type === "job.failed" && event.job_id) {
console.error("Job failed:", event.job_id, event.error_message);
}
res.status(200).json({ received: true });
},
);Verify Signatures
LeapOCR signs every outbound webhook with HMAC-SHA256. The X-R2-Signature
header contains the lowercase hex digest of the raw request body, signed with
your webhook secret.
Verify the signature against the raw request body exactly as it was received. If you parse and re-serialize JSON before verifying, the signature check can fail.
Verify with an SDK Helper
import express from "express";
import { LeapOCR } from "leapocr";
const app = express();
app.post(
"/leapocr/webhooks",
express.raw({ type: "application/json" }),
async (req, res) => {
const rawBody = req.body.toString("utf8");
const signature = req.header("X-R2-Signature") ?? "";
const secret = process.env.LEAPOCR_WEBHOOK_SECRET ?? "";
if (!(await LeapOCR.verifyWebhookSignature(rawBody, signature, secret))) {
res.status(401).send("Invalid signature");
return;
}
const event = JSON.parse(rawBody);
res.status(200).json({ received: true, eventType: event.event_type });
},
);import json
import os
from fastapi import FastAPI, Header, HTTPException, Request
from leapocr import LeapOCR
app = FastAPI()
@app.post("/leapocr/webhooks")
async def leapocr_webhook(
request: Request,
x_r2_signature: str | None = Header(default=None),
):
raw_body = await request.body()
secret = os.environ.get("LEAPOCR_WEBHOOK_SECRET", "")
if not LeapOCR.verify_webhook_signature(raw_body, x_r2_signature or "", secret):
raise HTTPException(status_code=401, detail="Invalid signature")
event = json.loads(raw_body)
return {"received": True, "eventType": event["event_type"]}<?php
use LeapOCR\LeapOCR;
$rawBody = file_get_contents('php://input') ?: '';
$signature = $_SERVER['HTTP_X_R2_SIGNATURE'] ?? '';
$secret = (string) getenv('LEAPOCR_WEBHOOK_SECRET');
if (!LeapOCR::verifyWebhookSignature($rawBody, $signature, $secret)) {
http_response_code(401);
echo 'Invalid signature';
exit;
}
$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
http_response_code(200);
echo json_encode(['received' => true, 'eventType' => $event['event_type']]);package main
import (
"encoding/json"
"io"
"net/http"
"os"
ocr "github.com/leapocr/leapocr-go"
)
func leapocrWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Invalid body", http.StatusBadRequest)
return
}
signature := r.Header.Get("X-R2-Signature")
secret := os.Getenv("LEAPOCR_WEBHOOK_SECRET")
if !ocr.VerifyWebhookSignature(rawBody, signature, secret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var event map[string]any
if err := json.Unmarshal(rawBody, &event); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"received": true,
"eventType": event["event_type"],
})
}Delivery Behavior
LeapOCR retries temporary delivery failures automatically:
- Transport errors are retried automatically.
429,408, and5xxresponses are retried with backoff.- Other
4xxresponses are treated as final delivery failures. - Successful deliveries reset the subscription failure counter.
- After 5 consecutive failed deliveries, the subscription is automatically disabled.
Your endpoint should return a 2xx response as soon as the event is accepted. If you need to do heavier work, queue it in your own background job system after verification.
Best Practices
- Respond with
2xxas soon as the event is accepted. - Verify
X-R2-Signaturebefore doing any work. - Store and de-duplicate
event_idvalues in your own system. - Regenerate the secret immediately if you suspect it was exposed.
- Use the dashboard test action before switching real automations on.
For exact request and response schemas, see the SDK API Reference or download the OpenAPI spec.