A synchronous run holds the connection for its whole duration, which is right when your agent is waiting on the answer and wrong when the work is a ten-minute crawl. Jobs are the same execution with a different cadence. This guide covers the three ways to learn a job finished, verifying the callback, and the two terminal-ish states that catch people out.
Submitting a job
POST /api/v1/jobs takes the same body as /run plus optional callbackUrl and callbackSecret, and answers 202 with a jobId. The worst-case cost is reserved up front, so a job cannot start work it cannot pay for. Pricing matches a run: a 10-credit floor settled higher-of against metered cost.
curl -X POST https://twin-browser.com/api/v1/jobs \
-H "Authorization: Bearer $TWIN_API_KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://portal.example.com/reports",
"prompt": "download every statement for Q2",
"success": { "kind": "extracted" },
"account": "acme-ops",
"callbackUrl": "https://your.app/hooks/twin",
"callbackSecret": "whsec_…"
}'
# → 202 { "jobId": "job_…" }Three ways to find out it finished
Pick one and stick to it. Polling GET /api/v1/jobs/{id} is the simplest and settles billing on the first terminal status it observes. GET /api/v1/jobs/{id}/stream emits status frames over Server-Sent Events as the engine transitions, then a final authoritative frame — and settles once on close. A callback pushes the result to you and does not require you to hold anything open.
- Poll: GET /api/v1/jobs/{id}.
- Stream: GET /api/v1/jobs/{id}/stream — SSE status frames, settles on close.
- Push: callbackUrl + callbackSecret — one POST on a terminal status.
- A client that disconnects from the stream early settles on its next poll; it does not get a free run.
Verifying the callback
The delivery carries x-twin-event (job.completed, job.failed or job.cancelled), x-twin-job-id, and — when you set a secret — x-twin-signature: sha256=<hex>, an HMAC-SHA256 of the RAW body. Verify over the raw bytes, before parsing: re-serializing the JSON changes them and the signature will never match. Delivery is best-effort with a couple of retries and never blocks the job.
import hmac, hashlib, os
def verify(raw_body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(
os.environ["TWIN_WEBHOOK_SECRET"].encode(),
raw_body, # RAW bytes — never a re-dumped dict
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, header or "")
# flask: verify(request.get_data(), request.headers.get("x-twin-signature"))The state people forget: paused
A sign-in job parks exactly like a synchronous run. GET /api/v1/jobs/{id} then reports status "paused" with a sessionId, and you continue it with POST /api/v1/runs/{id}/resume — the run endpoint, not a job one. A poller that only branches on complete and failed will sit on a paused job forever.
The other one: cancelled
POST /api/v1/jobs/{id}/cancel best-effort aborts the in-flight engine work, flips the run to cancelled and refunds the reservation in full. It is idempotent — cancelling an already-terminal job is a no-op that returns its current state with cancelled: false. What it does NOT do is undo side effects already performed on the target site, so cancel is a budget control, not an undo.
curl -X POST https://twin-browser.com/api/v1/jobs/job_…/cancel \
-H "Authorization: Bearer $TWIN_API_KEY"
# { "jobId": "job_…", "status": "cancelled", "cancelled": true }Which work belongs in a job
Anything that may outlive your request timeout, and anything you want several of in flight at once. A crawl is always a job and is priced per page (3 credits each) rather than at the job floor; a deep search is a job priced per scraped page. Recurring work is not a job at all — that is a monitor.