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 kind | Created by | Status endpoint | Webhook event |
|---|---|---|---|
| Ingest job | POST /v1/ingest/files | GET /v1/ingest/jobs/{jobId} | ingest.completed |
| Crawl job | POST /v1/ingest/crawl | GET /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:
| Status | Terminal | Meaning |
|---|---|---|
processing | No | Queued 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. |
completed | Yes | Every item succeeded. |
completed_with_errors | Yes | At least one item succeeded and at least one failed. errors[] lists the failures. |
failed | Yes | Nothing 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."
}
]
}
| Field | Type | Description |
|---|---|---|
job_id | string | The job's UUID, as returned by POST /v1/ingest/files. |
status | string | See Job status. |
total_documents | integer | Number of documents in the job (one per uploaded file). |
completed_documents | integer | Documents that reached ready. |
failed_documents | integer | Documents that ended failed. |
errors | object[] | 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
| Event | Fires when | Identifier |
|---|---|---|
ingest.completed | An ingest job reaches completed, completed_with_errors, or failed | job_id |
crawl.completed | A crawl job reaches completed, completed_with_errors, or failed | crawl_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."}]}
| Field | Type | Description |
|---|---|---|
event | string | ingest.completed. |
job_id | string | The ingest job's UUID. |
namespace | string | The knowledge-base namespace the files were ingested into. |
status | string | completed, completed_with_errors, or failed. |
total_documents | integer | Documents in the job (one per uploaded file). |
completed_documents | integer | Documents that reached ready. |
failed_documents | integer | Documents that ended failed. |
errors | object[] | {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."}]}
| Field | Type | Description |
|---|---|---|
event | string | crawl.completed. |
crawl_job_id | string | The crawl job's UUID. |
namespace | string | The knowledge-base namespace that received the pages. |
status | string | completed, completed_with_errors, or failed. |
pages_crawled | integer | Pages fetched successfully, including pages with no text. |
pages_indexed | integer | Pages that became ready documents. |
errors | object[] | {url, error} per failed page, or one crawl entry when the crawl itself failed, as on the status endpoint. |
Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-ECD-Event | The event name: ingest.completed or crawl.completed. |
X-ECD-Delivery-Id | A UUID identifying this event. It is the same on every retry of the same event, so use it to deduplicate (Idempotent handling). |
X-ECD-Signature | t=<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:
- 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.
- Split the header on
,, then each part on=, to obtaintandv1. - Compute HMAC-SHA256 over
t + "." + bodywith your secret and compare it withv1using a constant-time comparison. - Reject the delivery if
tis 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 currentt. - 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
2xxstatus within 10 seconds of receiving the request. Any other status, a redirect (3xxresponses 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
2xxstops the retries. The sameX-ECD-Delivery-Idand the same body are sent on every attempt, with a freshX-ECD-Signature(newtandv1) 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_idorcrawl_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
4xxand alert on them. - Return
2xxwithin 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:
| Status | Code | When |
|---|---|---|
401 | invalid_api_key | Missing or invalid key. |
403 | insufficient_scope | The key lacks the ingest scope. |
404 | not_found | Unknown job id, or a job created under another account. |
429 | rate_limit_exceeded, insufficient_quota | Per-minute request limit or daily token quota reached; honor Retry-After. |
All errors use the standard envelope described in Errors.