Concepts

Async & webhooks

Long documents without held-open connections: pass async: true, get a job id in milliseconds, poll — or let a signed callback bring the result to you.

Enqueue

The document endpoints (compare, contract, invoice, parse, receipt, resume, split, statement, tables) accept async: true.

Terminal
curl https://api.kynth.studio/v1/parse \
  -H "Authorization: Bearer $KYNTH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "data": "<base64 pdf>", "async": true,
        "callbackUrl": "https://yourapp.com/hooks/kynth" }'
# 202 { "jobId": "6f1d2c8e-…", "status": "queued", "requestId": "req_…" }

Poll

Status is one of queued, running, succeeded, failed — the last two are terminal. Billing is unchanged: a job is charged when it succeeds, never on enqueue and never when it fails. Your jobs are also visible in the console.

Terminal
curl https://api.kynth.studio/v1/jobs/6f1d2c8e-… \
  -H "Authorization: Bearer $KYNTH_API_KEY"
# { "status": "succeeded", "result": { …, "usage": { … } } }

Webhooks

Pass callbackUrl (a public https URL) and we POST the finished job to it once, on completion:

HeaderValue
X-Kynth-Eventjob.completed | job.failed
X-Kynth-Job-Idthe job's id
X-Kynth-Request-Idthe originating request id
X-Kynth-Signaturesha256=<hex HMAC-SHA256(raw body, your signing secret)>

The signature is HMAC-SHA256 over the raw request body, keyed with your account's signing secret — find it under Webhooks in the console. Verify before you trust the body, and compare in constant time:

Verify (Node)
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string, secret: string) {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const got = header.replace(/^sha256=/, "");
  return got.length === expected.length &&
    timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}

What we guarantee

A job is durably recorded the moment you get the 202, and every job reaches a terminal state. If the instance running yours dies mid-flight, a sweeper marks it failed with an explanation and fires your callback — you're never left polling running forever, and you're never billed for it. We don't silently retry: resubmit and you stay in control of the spend. Callback delivery is best-effort and not retried — polling is the source of truth. Redirects are not followed, and a callbackUrl that resolves to a private address is refused.