ViperCapture
API documentation

Async render jobs

Submit an image render, poll its status, and download the result.

When to use a job

Use POST /v1/jobs when the client cannot keep a render request open or when a submission retry must return the existing job. Use POST /v1/render for synchronous requests. Async jobs accept exactly one PNG, JPEG, or WebP output with cache: false. PDF, HTML, Markdown, metadata, viewport packs, cache mode, and webhooks are not supported by this endpoint.

Create a job

The JSON body uses the same strict render fields as POST /v1/render. Creation consumes one API attempt. Credits are reserved only when a worker begins execution and settle only after the artifact has been rendered, validated, and stored successfully.

request.bash
curl --fail-with-body https://capture.viperisuseful.cc/v1/jobs \
  -H "Authorization: Bearer $VIPERCAPTURE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Request-Id: report-cover-2026-08-01" \
  --data '{
    "url": "https://example.com",
    "output": "png",
    "viewport": { "width": 1440, "height": 900 },
    "full_page": true
  }'

A successful submission returns HTTP 202, a Locationstatus path, Retry-After: 1, and a private no-store job document.

request.json
{
  "id": "0198...",
  "request_id": "report-cover-2026-08-01",
  "status": "queued",
  "attempts": 0,
  "status_url": "/v1/jobs/0198...",
  "result_url": null,
  "created_at": "2026-08-01T18:00:00+00:00",
  "started_at": null,
  "completed_at": null,
  "result_expires_at": null,
  "result": null,
  "credits": null,
  "timings": null,
  "error": null
}

Submit a bounded batch

POST /v1/jobs/bulk accepts 1 to 10 independent job items in a request body up to 6 MiB. Each item requires its own unique, stable request_id, contains the normal strict async render body under render, and may include an optional correlationid. Authentication happens once, while each item consumes one API attempt and applies the same idempotency and active-job limits as POST /v1/jobs.

request.bash
curl --fail-with-body https://capture.viperisuseful.cc/v1/jobs/bulk \
  -H "Authorization: Bearer $VIPERCAPTURE_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
    "items": [
      {
        "id": "desktop",
        "request_id": "release-42-desktop",
        "render": {"url": "https://example.com", "output": "png"}
      },
      {
        "id": "mobile",
        "request_id": "release-42-mobile",
        "render": {
          "url": "https://example.com",
          "output": "webp",
          "viewport": {"width": 390, "height": 844}
        }
      }
    ]
  }'

A valid envelope returns HTTP 200 with count,accepted, failed, and orderedresults. Every result includes its input index, correlation ID, request ID, acceptance flag, and item status. Accepted entries contain the normal job document; rejected entries contain a typed error. Submission is best effort, not transactional: one item reaching an attempt, account, or global limit does not roll back jobs already accepted or stop later items from being checked. Retry only rejected items with their original request IDs.

Retry a submission

Supply a stable X-Request-Id for every logical job. Sending the same request ID and the exact same normalized body again returns the original job, even when the account is already at its active-job or render-concurrency limit. Job submission uses the attempt allowance and active-job caps but does not reserve a browser in-flight slot; workers acquire that slot only when execution begins. Reusing that ID with any different render field returns HTTP 409 with idempotency_key_conflict. ViperCapture compares an HMAC request fingerprint; it does not store a plaintext copy for this check. A repeated POST is still an authenticated API attempt, so stop retrying once a 202 response is received.

Poll and download

GET /v1/jobs/{job_id} returns the current state. HonorRetry-After, poll about once per second, and stop at a terminal state. Polls and result downloads do not consume API attempts or credits. After success, GET /v1/jobs/{job_id}/result returns the binary image.

request.bash
curl --fail-with-body \
  -H "Authorization: Bearer $VIPERCAPTURE_API_KEY" \
  https://capture.viperisuseful.cc/v1/jobs/$JOB_ID

curl --fail-with-body \
  -H "Authorization: Bearer $VIPERCAPTURE_API_KEY" \
  https://capture.viperisuseful.cc/v1/jobs/$JOB_ID/result \
  --output capture.png

Both endpoints require an API key owned by the account that created the job. A successful status contains result media type, filename and byte count; actual credit cost and source; queue and render milliseconds;attempts; and result_expires_at. Downloads use private no-store headers and are limited to two concurrent transfers across the service. A missing or expired object returns async_result_expiredwith HTTP 410 instead of an empty or corrupt response.

State model

StatusMeaning
queuedWaiting for a worker; DELETE may cancel it.
runningRendering, validating, uploading, or settling credits.
succeededThe authenticated result is ready for four hours.
failedTerminal failure; inspect error.code and error.retryable.
cancelledCancelled before execution; input and reservation are gone.
expiredThe queue deadline or result-download window elapsed.

Execution retries

A retryable render or infrastructure failure is requeued with bounded backoff, up to three total execution attempts and never beyond the original 15-minute start deadline. Non-retryable failures terminate immediately. Each database claim carries a stable acknowledgement token so a lost database response cannot claim a second job. Every mutation also checks the expected attempt number, preventing a late worker from overwriting a newer retry. Exhausted work ends withasync_attempts_exhausted and retryable: false.

One attempt has at most one credit reservation. Failed attempts release the reservation. Credit settlement for a staged artifact is idempotent, so a database retry or process restart does not render or charge it twice. The reported render timing spans execution from the first claim through retry backoff and final delivery; queue timing covers creation to the first worker claim.

Queue, fairness, and retention

  • Bulk submissions accept 1–10 items and a maximum 6 MiB JSON body.
  • Active jobs per account: Free 1, Starter 3, Builder 10.
  • Global durable queue: 30 queued or running jobs.
  • Two background workers use the account-fair API scheduler and preserve the website's reserved browser capacity.
  • A queued job must start within 15 minutes; expiry releases reservations and does not settle credits.
  • Successful UploadThing images live for four hours; terminal Neon metadata is deleted after 24 hours.
  • Queue maintenance is throttled, while worker claims force an up-to-date expiry and exhausted-attempt check.

Cancellation and restart recovery

DELETE /v1/jobs/{id} cancels only queued work and immediately erases its encrypted input. Repeating deletion of a terminal job returns its current state. Running jobs returnjob_already_running; an upload or credit settlement already in progress cannot be safely undone.

On application restart, an unfinished unstaged render releases its reservation and requeues if attempts remain. A staged upload is checked against its reservation and expiry, then finalized without rendering or charging twice. Expired or invalid staged output terminates cleanly.

Input and result security

Queued URL, HTML, Markdown, and target-header values are protected with AES-GCM authenticated encryption before Neon storage using a key derived from the server secret. Plaintext exists only while an authorized worker processes the job. The encrypted payload is erased on success, final failure, cancellation, or queue expiry. The request fingerprint is keyed and contains no raw source or header values.

UploadThing stores an unguessable public object for at most four hours, but ViperCapture does not expose its provider key or URL. It proxies the result only after API-key ownership checks, validates media type and plan byte limits again on download, and stops provider work when the client disconnects.

Job-specific errors

  • idempotency_key_conflict (409): the request ID belongs to a different body.
  • job_not_found (404): no job owned by this API account has that ID.
  • job_not_ready (409): result requested before success; honor Retry-After.
  • job_already_running (409): running work cannot be cancelled.
  • async_account_limit (429) or async_queue_full (503): active capacity is full.
  • async_attempts_exhausted: three retryable executions failed.
  • async_result_expired (410): the four-hour object is gone.