Operations

Run browser work asynchronously and verify the callback

Submit a run as a background job, choose between polling, a status stream and a signed webhook, and handle the two states people forget: paused and cancelled.

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.

Submit with a completion webhookbash
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.

Verify in Pythonpython
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.

Cancel — full refundbash
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.

Common questions

When should I use a job instead of a run?
When the work may outlive your request timeout, or when you want several executions in flight. The trade is explicit: you gain durability and you take on owning a job id.
What does cancelling cost?
Nothing. The reservation is refunded in full. Cancelling an already-terminal job is a no-op that returns its current state.
Can I get partial results while it runs?
The status stream reports transitions, not partial output, and the callback fires on a terminal status. If you want to watch the browser itself, that is the live endpoint, not a job.
Is a crawl billed at the job floor?
No — a crawl is billed per page read (3 credits each), reserved against maxPages. The job floor applies to a goal submitted as a job.

Delegate the work. Keep the decision.

Hand off a real task, set the guardrails, and let repeated work compile into a skill that replays deterministically at near-zero cost.