Your first extraction, in depth.
The quickstart got you a result; this chapter explains the machinery. Every job field, the full status lifecycle, poll patterns that behave well, pagination, and cancellation.
You'll need a workspace with the trawl service enabled and an API key with the relevant trawl.* actions — Chapter 1 covers both. If you haven't set those up, start with 01 · Foundations.
1
Create the job
One POST enqueues an extraction. Two fields are required — the schema and the description — and everything else tunes how the job runs. Requires the trawl.create action.
curl https://api.trawl.productcraft.co/v1/workspaces/<workspace_id>/jobs \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-H 'idempotency-key: pricing-crawl-2026-07-10' \
-d '{
"description": "Extract the product name and the pricing plans (plan name and monthly USD price) from this pricing page.",
"json_schema": {
"type": "object",
"properties": {
"product_name": { "type": "string" },
"plans": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"monthly_price_usd": { "type": ["number", "null"] }
},
"required": ["name"]
}
}
},
"required": ["product_name", "plans"]
},
"suggested_urls": ["https://example.com/pricing"],
"timeout_s": 180
}'Field by field:
json_schema(required) — a JSON Schema describing the shape you want back. The top level must be a non-empty object with"type": "object"; a bare array, a primitive, or an empty{}is rejected with400. To extract a list, wrap it in an object property, asplansdoes above. Full design advice lives in 03 · Schema design.description(required) — a natural-language description of what to extract, up to 2,000 characters, non-empty. This is what drives the agent; the schema only shapes the output. Be specific about what to find and roughly where.suggested_urls(optional) — up to 50 seed URLs, eachhttporhttps. Without seeds, the agent searches the web to find sources itself; with seeds, it starts from your pages. URLs pointing at private or reserved addresses are rejected with400.webhook_id(optional, ≤ 64 characters) — the id of a webhook you've already created in this workspace; the job's terminal event is delivered to it. An id that doesn't belong to your workspace returns404before the job is created. Skip it for now — polling works without one, and 04 · Webhooks covers push delivery properly.timeout_s(optional, 1–3,600; default 120) — the wall-clock budget for the extraction, in seconds. The default is fine for one to three pages; raise it for research-style jobs that aggregate across many sources.max_steps(optional, 1–1,000; default 10) — accepted and stored on the job record. The service manages the agent's effective step budget itself today, so treattimeout_sas your budget lever.model_tier(optional, ≤ 32 characters; default"default") — leave it unset unless you have a specific reason not to.
The optional Idempotency-Key header (1–256 characters from A–Z a–z 0–9 _ - :) makes retries safe: replaying the same key with the same body within 24 hours returns the original response with the Idempotent-Replay: true header instead of creating a second job. The same key with a different body is rejected with 409 (IDEMPOTENCY_KEY_REUSE).
Two more wire facts worth internalizing now: everything is snake_case on the wire, in both directions, and unknown body fields are silently dropped rather than rejected — so a typo like timeout_seconds won't error, it just won't apply. Double-check field names against the API reference.
2
Read the response
A successful create returns 201 with the full job resource. The same shape comes back from every job read — learn it once.
Response — 201
{
"id": "3f2b6c1a-9d4e-4b7f-8a21-...",
"workspace_id": "<workspace_id>",
"status": "queued",
"json_schema": { "type": "object", "...": "..." },
"description": "Extract the product name and the pricing plans...",
"suggested_urls": ["https://example.com/pricing"],
"max_steps": 10,
"timeout_s": 180,
"model_tier": "default",
"webhook_id": null,
"result": null,
"error": null,
"attempts": 0,
"enqueued_at": "2026-07-10T09:00:00.000Z",
"started_at": null,
"completed_at": null,
"created_at": "2026-07-10T09:00:00.000Z"
}The first block echoes your inputs (with defaults filled in). The fields that change over the job's life:
status— where the job is in its lifecycle. Section 3 walks through every value.result—nulluntil the job succeeds; then the extracted data as a JSON object validated against your schema.error—nullunless the job failed; then a human-readable reason (section 7).started_at/completed_at— stamped when a worker picks the job up and when it reaches a terminal status.completed_at − started_atis your actual extraction time — useful for calibratingtimeout_s.attempts— an informational counter, incremented when a terminal outcome is recorded. Not a retry knob.
3
What each status means
Five statuses, one direction of travel. Terminal states are frozen — a job never leaves succeeded, failed, or cancelled.
queued— accepted and waiting for a worker. Normally brief. A job that's never picked up is failed automatically after 30 minutes, so nothing waits forever.running— a worker has claimed the job (started_atstamped) and the agent is browsing. The job stays here until it finishes, hits its budget, or you cancel it.succeeded— the agent returned data that validated against your schema.resultis populated;errorisnull. Non-conforming output is never delivered as a success.failed— the extraction didn't produce a valid result.errorsays why;resultisnull. There are no automatic re-runs — retrying means submitting a new job.cancelled— you cancelled it while it was stillqueuedorrunning(section 6).
Overrunning jobs are cleaned up server-side: a running job that exceeds its timeout_s is failed by a periodic sweep with error set to job exceeded its time budget (reaped). Budget enforcement isn't instantaneous — expect a stuck job to reach failed within roughly timeout_s plus a one-minute grace period plus up to five minutes of sweep cadence. Design pipelines around “terminal eventually,” not “terminal at exactly timeout_s.”
4
Poll to completion
The result lives on the job row, so GET-until-terminal is a first-class way to collect it — no webhook required. Requires the trawl.read action.
curl https://api.trawl.productcraft.co/v1/workspaces/<workspace_id>/jobs/<job_id> \
-H 'authorization: Bearer pcft_live_...'Pattern notes:
- Interval: a few seconds is the right order of magnitude — 5 seconds against a 120-second default budget means at most ~24 requests per job. For long
timeout_sjobs, widen the interval accordingly; sub-second polling buys you nothing. - Stop on all three terminal statuses (
succeeded,failed,cancelled), not justsucceeded— otherwise a failed job spins your loop forever. - Bound the loop in production: give up after
timeout_splus several minutes of headroom, since budget enforcement itself has slack (section 3). - 404 means “not yours or not there”: an unknown job id — including one that belongs to another workspace — returns
404withJob not found. A non-UUID id returns400.
Once you're running real volume, replace the poll loop with a signed webhook — 04 · Webhooks — and keep GET as the reconciliation path.
5
List jobs, with pagination
Newest first, cursor-paginated, filterable by status. Requires the trawl.list action.
curl 'https://api.trawl.productcraft.co/v1/workspaces/<workspace_id>/jobs?status=failed&limit=50' \
-H 'authorization: Bearer pcft_live_...'Response — 200
{
"data": [
{ "id": "…", "status": "failed", "error": "…", "...": "..." },
{ "id": "…", "status": "failed", "error": "…", "...": "..." }
],
"pagination": {
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0xMFQwOTowMDo…",
"has_more": true
}
}Query parameters:
status(optional) — one ofqueued,running,succeeded,failed,cancelled.limit(optional) — 1–100, default 20.cursor(optional) — the opaquenext_cursorfrom the previous page. Never parse or construct it.
Keep fetching while has_more is true:
async function listJobs(workspaceId: string, status?: string) {
const jobs = [];
let cursor: string | null = null;
do {
const qs = new URLSearchParams({ limit: '100' });
if (status) qs.set('status', status);
if (cursor) qs.set('cursor', cursor);
const res = await fetch(
`${BASE}/v1/workspaces/${workspaceId}/jobs?${qs}`,
{ headers: { authorization: `Bearer ${process.env.PCFT_KEY}` } },
);
const page = await res.json();
jobs.push(...page.data);
cursor = page.pagination.has_more ? page.pagination.next_cursor : null;
} while (cursor);
return jobs;
}
const failed = await listJobs(process.env.WORKSPACE_ID!, 'failed');Ordering is stable keyset pagination on creation time — new jobs created while you paginate won't shuffle the pages you've already read.
6
Cancel a job
Changed your mind, or submitted with the wrong schema? Cancel while the job is still queued or running. Requires the trawl.cancel action.
curl -X POST https://api.trawl.productcraft.co/v1/workspaces/<workspace_id>/jobs/<job_id>/cancel \
-H 'authorization: Bearer pcft_live_...'Response — 200
{
"id": "3f2b6c1a-9d4e-4b7f-8a21-...",
"status": "cancelled",
"result": null,
"error": null,
"completed_at": "2026-07-10T09:00:12.000Z",
"...": "..."
}- Only
queuedandrunningjobs can be cancelled. A job that has already reached a terminal status is rejected — checkstatusbefore cancelling if you're racing a fast job. - Cancellation is a bookkeeping operation: an extraction already in flight isn't interrupted mid-browse, but its late result is discarded — a cancelled job never flips to
succeededafterwards. - No webhook event fires for a cancellation. Webhooks deliver
job.succeededandjob.failedonly. - Unknown id (or another workspace's job) →
404.
7
When jobs fail
Failures come with a human-readable error string on the job row. The common ones, and what to change before resubmitting:
extraction did not complete within the step/time budget— the agent ran out of time or steps before finishing. Raisetimeout_s, provide bettersuggested_urlsso it doesn't spend the budget searching, or narrow thedescriptionto fewer facts.agent could not complete the extraction— the agent finished but judged that it couldn't actually satisfy the request; a “couldn't-do-it” is never shipped as a success. Rework the description, or make hard-to-find fields nullable so a partial answer is representable.result failed schema validation— the agent's output didn't conform to your schema. Usually an over-constrained schema; loosen strictrequiredlists and tight formats. See 03 · Schema design.invalid json_schema: …— the stored schema uses a construct the runtime converter can't handle. Stick to plaintype/properties/required/items/enumshapes.seed URL rejected: …— a suggested URL resolved to a private or reserved address at run time. Remove it; Trawl extracts from the public web only.job exceeded its time budget (reaped)— the job was cleaned up by the server-side sweep: it overran its budget, or was never picked up within 30 minutes. Safe to resubmit as-is; if it recurs, treat it like the budget failure above.
In every case the job row is the debugging record: error tells you what happened, started_at / completed_at tell you how long it ran, and resubmitting with adjusted inputs is the retry mechanism — there is no automatic re-run. Chapter 5 covers operating this at volume: 05 · Operations.