Skip to main content

Jobs and webhooks

Uploading files and crawling websites both return immediately and do the work in the background. A job tracks that work: its status, its counts, and the per-item errors. You learn that a job has finished either by polling its status endpoint or by receiving a signed webhook.

Job kindCreated byStatus endpointWebhook event
Ingest jobPOST /v1/ingest/filesGET /v1/ingest/jobs/{jobId}ingest.completed
Crawl jobPOST /v1/ingest/crawlGET /v1/ingest/crawl/{crawlJobId}crawl.completed

Both status endpoints require the ingest scope and return only jobs created under your account.

Job status

Ingest and crawl jobs share one status vocabulary:

StatusTerminalMeaning
processingNoQueued or running. Counts and errors[] are live, updated as each document or page is processed; for ingest jobs this status also covers the time between automatic retries.
completedYesEvery item succeeded.
completed_with_errorsYesAt least one item succeeded and at least one failed. errors[] lists the failures.
failedYesNothing succeeded (every item failed, or the job itself failed, for example a crawl that hit an internal error, timed out, or was cancelled). A crawl that timed out after indexing some pages reports them in pages_indexed.

A job moves from processing to exactly one terminal status and never changes afterwards; a terminal job is never processed again. Counts (completed_documents and failed_documents, or pages_crawled and pages_indexed) are final only in a terminal status. A webhook, when configured, fires exactly once, when the job reaches its terminal status.

Terminal does not mean error-free: always inspect errors[] on completed_with_errors and failed. Each failed document or page also ends as a document in status failed carrying the same error text (Documents), with three exceptions: a document you deleted while the job was running is gone and appears only in errors[] (error Document was deleted before it was processed.); a page the site answered with an HTTP error status appears only in errors[] (error HTTP <status>); and a whole-crawl failure is reported once, under the crawl sentinel, with no document.

Ingest jobs

curl -s "https://api.chatbots.ecourtdate.com/v1/ingest/jobs/bfcde661-9eec-4142-a790-ca9a62c8e0f8" \
-H "Authorization: Bearer $API_KEY"

While running:

{
"job_id": "bfcde661-9eec-4142-a790-ca9a62c8e0f8",
"status": "processing",
"total_documents": 3,
"completed_documents": 1,
"failed_documents": 0,
"errors": []
}

Finished:

{
"job_id": "bfcde661-9eec-4142-a790-ca9a62c8e0f8",
"status": "completed_with_errors",
"total_documents": 3,
"completed_documents": 2,
"failed_documents": 1,
"errors": [
{
"document_id": "4b1f0c8e-2a77-4c0f-9d3e-6f2a1b9c7d10",
"error": "No readable text was found in this document. Scanned or image-only files must be run through OCR before upload."
}
]
}
FieldTypeDescription
job_idstringThe job's UUID, as returned by POST /v1/ingest/files.
statusstringSee Job status.
total_documentsintegerNumber of documents in the job (one per uploaded file).
completed_documentsintegerDocuments that reached ready.
failed_documentsintegerDocuments that ended failed.
errorsobject[]One entry per failed document: document_id and a sanitized error (Per-document errors).

In a terminal status completed_documents + failed_documents equals total_documents.

An unknown job id, or a job created under another account, returns 404 not_found.

Automatic retries

A transient failure while processing a document (for example a temporary failure computing text embeddings) is retried automatically, up to three attempts in total, with a short delay between attempts. While a retry is pending the job remains processing, the counts do not move, and the affected document stays in status processing (the document vocabulary is only processing, ready, and failed). If the third attempt also fails, the document ends failed with the error Processing failed due to an internal error. Please retry; contact support if the problem persists. and the job finishes.

Crawl jobs are not retried: a page that fails is recorded in errors[] and the crawl moves on.

Crawl jobs

curl -s "https://api.chatbots.ecourtdate.com/v1/ingest/crawl/4631b07b-8c2d-4f1e-9a6b-3d5e7f9a1b2c" \
-H "Authorization: Bearer $API_KEY"
{
"crawl_job_id": "4631b07b-8c2d-4f1e-9a6b-3d5e7f9a1b2c",
"status": "completed",
"pages_crawled": 52,
"pages_indexed": 50,
"errors": []
}

The fields (crawl_job_id, status, pages_crawled, pages_indexed, errors[] of {url, error}), the crawl sentinel for whole-crawl failures, and the per-page error strings are documented in Crawling websites.

Polling

Poll the status endpoint until status is anything other than processing. A small upload is often done within seconds; a large PDF set can take many minutes, and a crawl runs for at most its 10-minute budget. Use exponential backoff between polls so a long job does not burn your per-minute request budget, and stop at a ceiling of 30 to 60 seconds:

import os
import time
import requests

API_KEY = os.environ["API_KEY"]
BASE_URL = "https://api.chatbots.ecourtdate.com/v1"

def wait_for(path, interval=2.0, max_interval=30.0, timeout=3600.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
r = requests.get(
f"{BASE_URL}{path}",
headers={"Authorization": f"Bearer {API_KEY}"},
)
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", "5")))
continue
r.raise_for_status()
job = r.json()
if job["status"] != "processing":
return job
time.sleep(interval)
interval = min(interval * 2, max_interval)
raise TimeoutError(path)

ingest = wait_for("/ingest/jobs/bfcde661-9eec-4142-a790-ca9a62c8e0f8")
crawl = wait_for("/ingest/crawl/4631b07b-8c2d-4f1e-9a6b-3d5e7f9a1b2c")

Polling requests count toward your per-minute limit like any other call, and a 429 carries Retry-After. The RateLimit-Remaining header on each response tells you how much budget is left in the current window.

For anything beyond a one-off script, prefer webhooks and keep polling as the fallback.

Webhooks

When a job reaches a terminal status, the API can POST a JSON event to an HTTPS endpoint you operate. The webhook URL and its signing secret are configured for your account by eCourtDate: contact support to set them, to rotate the secret, or to remove the URL. There is no API to manage them.

Events

EventFires whenIdentifier
ingest.completedAn ingest job reaches completed, completed_with_errors, or failedjob_id
crawl.completedA crawl job reaches completed, completed_with_errors, or failedcrawl_job_id

Each event body mirrors the job object from the status endpoint (the same counts and errors[]) plus event and namespace, so a handler usually does not need a follow-up GET. The event name appears both in the X-ECD-Event header and in the body's event field. The body is compact JSON (no whitespace between tokens).

ingest.completed:

{"event":"ingest.completed","job_id":"bfcde661-9eec-4142-a790-ca9a62c8e0f8","namespace":"general","status":"completed_with_errors","total_documents":3,"completed_documents":2,"failed_documents":1,"errors":[{"document_id":"4b1f0c8e-2a77-4c0f-9d3e-6f2a1b9c7d10","error":"No readable text was found in this document. Scanned or image-only files must be run through OCR before upload."}]}
FieldTypeDescription
eventstringingest.completed.
job_idstringThe ingest job's UUID.
namespacestringThe knowledge-base namespace the files were ingested into.
statusstringcompleted, completed_with_errors, or failed.
total_documentsintegerDocuments in the job (one per uploaded file).
completed_documentsintegerDocuments that reached ready.
failed_documentsintegerDocuments that ended failed.
errorsobject[]{document_id, error} per failed document, as on the status endpoint.

crawl.completed:

{"event":"crawl.completed","crawl_job_id":"4631b07b-8c2d-4f1e-9a6b-3d5e7f9a1b2c","namespace":"public-site","status":"completed_with_errors","pages_crawled":148,"pages_indexed":146,"errors":[{"url":"https://courts.example.gov/forms/fee-waiver","error":"The page could not be retrieved or contained no readable text."}]}

A crawl that times out still sends the event, as failed with the single crawl entry:

{"event":"crawl.completed","crawl_job_id":"4631b07b-8c2d-4f1e-9a6b-3d5e7f9a1b2c","namespace":"public-site","status":"failed","pages_crawled":12,"pages_indexed":0,"errors":[{"url":"crawl","error":"Crawl timed out."}]}
FieldTypeDescription
eventstringcrawl.completed.
crawl_job_idstringThe crawl job's UUID.
namespacestringThe knowledge-base namespace that received the pages.
statusstringcompleted, completed_with_errors, or failed.
pages_crawledintegerPages fetched successfully, including pages with no text.
pages_indexedintegerPages that became ready documents.
errorsobject[]{url, error} per failed page, or one crawl entry when the crawl itself failed, as on the status endpoint.

Request headers

HeaderValue
Content-Typeapplication/json
X-ECD-EventThe event name: ingest.completed or crawl.completed.
X-ECD-Delivery-IdA UUID identifying this event. It is the same on every retry of the same event, so use it to deduplicate (Idempotent handling).
X-ECD-Signaturet=<unix seconds>,v1=<hex>: the signature over the timestamp and the raw body (Verifying signatures). Both t and v1 are recomputed on every delivery attempt. Present only when a signing secret is configured for your account.

Deliveries are unsigned only when no secret is configured. Ask eCourtDate to set one before you rely on webhooks, and reject unsigned deliveries in your handler.

Verifying signatures

X-ECD-Signature has the form t=<timestamp>,v1=<signature>, where <timestamp> is the Unix time (seconds) at which this attempt was signed and <signature> is the 64-character lowercase hex HMAC-SHA256, keyed with your webhook secret, of the string

<timestamp> + "." + <raw request body>

To verify a delivery:

  1. Read the raw request body bytes. Do not parse and re-serialize the JSON first: any change in key order or whitespace changes the signature.
  2. Split the header on ,, then each part on =, to obtain t and v1.
  3. Compute HMAC-SHA256 over t + "." + body with your secret and compare it with v1 using a constant-time comparison.
  4. Reject the delivery if t is more than a few minutes (5 is a sensible tolerance) away from the current time. The timestamp is part of the signed material, so a captured delivery cannot be replayed later with a fresh timestamp. Retries are signed afresh, so a retried delivery always carries a current t.
  5. Only then parse the JSON and act on it.

Python verification example (standard library only):

import hmac
import hashlib
import time

TOLERANCE_SECONDS = 300

def verify_signature(header: str, body: bytes, secret: str) -> bool:
try:
parts = dict(item.split("=", 1) for item in header.split(","))
timestamp = int(parts["t"])
provided = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}.".encode() + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, provided)

Used in a request handler (the framework must give you the unparsed body):

import json
import os

WEBHOOK_SECRET = os.environ["ECD_WEBHOOK_SECRET"]

def handle_webhook(headers, raw_body: bytes):
signature = headers.get("X-ECD-Signature", "")
if not verify_signature(signature, raw_body, WEBHOOK_SECRET):
return 400, "invalid signature"
event = json.loads(raw_body)
if event["event"] == "ingest.completed":
on_ingest_completed(event, headers["X-ECD-Delivery-Id"])
elif event["event"] == "crawl.completed":
on_crawl_completed(event, headers["X-ECD-Delivery-Id"])
return 200, "ok"

Node verification example, as an Express handler that keeps the raw body:

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;
const secret = process.env.ECD_WEBHOOK_SECRET;

function verifySignature(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(",").map((item) => item.split("=", 2)),
);
const timestamp = Number.parseInt(parts.t, 10);
if (!Number.isFinite(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

const expected = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
const provided = Buffer.from(parts.v1, "hex");
return expected.length === provided.length && timingSafeEqual(expected, provided);
}

const app = express();

app.post(
"/webhooks/ecourtdate",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-ECD-Signature") ?? "";
if (!verifySignature(header, req.body, secret)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
const deliveryId = req.get("X-ECD-Delivery-Id");
// Acknowledge first, then do the work asynchronously.
res.status(200).end();
void processEvent(event, deliveryId);
},
);

Delivery and retries

  • The job record is written to its terminal status before the webhook is sent, so a handler that calls the status endpoint always sees the final state.
  • Your endpoint must respond with a 2xx status within 10 seconds of receiving the request. Any other status, a redirect (3xx responses are not followed), a connection error, or a timeout counts as a failed attempt.
  • Up to 3 attempts are made: immediately, then 30 seconds later, then 5 minutes later. The first 2xx stops the retries. The same X-ECD-Delivery-Id and the same body are sent on every attempt, with a fresh X-ECD-Signature (new t and v1) each time.
  • After the third failure the event is dropped and logged. Delivery is best effort: there is no dead-letter queue and no way to replay a delivery.
  • A delivery failure never changes the job's status. The status endpoint is the source of truth; if your endpoint was down, poll for jobs you are still waiting on.
  • One event is sent per job, only at its terminal status. A job that is still being retried internally sends nothing until it finishes.

Respond quickly: acknowledge with 2xx as soon as the signature checks out and do the real work (fetching documents, updating your records) after responding, as in the examples above. A handler that does its work before responding risks the 10-second timeout and a duplicate delivery.

Idempotent handling

Because a slow handler or a lost response can cause the same event to be delivered more than once, make handlers idempotent:

  • Key on X-ECD-Delivery-Id. Store the ids you have processed and ignore a delivery whose id you have already seen. The id is stable across retries of the same event.
  • As a belt-and-braces check, the job id (job_id or crawl_job_id) also identifies the event uniquely, since each job produces exactly one event.
  • Treat the payload as a notification, not a command: the action "mark this upload as done in my system" should be safe to repeat.

Endpoint checklist

  • Serve the endpoint on a public host, over HTTPS. The URL must be reachable from the internet; private or internal addresses cannot be configured.
  • Keep the raw body available to the signature check (most frameworks need a raw-body middleware on the webhook route).
  • Reject unsigned or badly signed deliveries with a 4xx and alert on them.
  • Return 2xx within 10 seconds; queue the work.
  • Deduplicate on X-ECD-Delivery-Id.
  • Keep polling as a fallback for the rare dropped delivery.

Errors

The two status endpoints can return:

StatusCodeWhen
401invalid_api_keyMissing or invalid key.
403insufficient_scopeThe key lacks the ingest scope.
404not_foundUnknown job id, or a job created under another account.
429rate_limit_exceeded, insufficient_quotaPer-minute request limit or daily token quota reached; honor Retry-After.

All errors use the standard envelope described in Errors.