AiContent Services - Integration Guide

Canonical reference for apps and AI agents that need to consume our services. Every service below uses the same standardized section template:

  1. Overview - what it does, base URL
  2. Auth - how to authenticate
  3. Pricing & quotas - credits, limits, billing
  4. Endpoints - request/response shapes
  5. Errors - HTTP status meanings + retry guidance
  6. TypeScript client - drop-in sample
  7. Python client - drop-in sample
  8. Admin SQL - managing clients / inspecting usage

For agents: when integrating one of our services into another project, you only need the section for that service. Copy the env vars, drop in the client sample, done.


Services index

Service Base URL Status
AiContent Scraper API: https://scraperapi.aicontent.engineering · Marketing: https://scraper.aicontent.engineering Live

AiContent Scraper

Overview

Stealth headless-browser web scraping. Executes JavaScript, evades bot detection (Cloudflare, Datadome, PerimeterX), returns the full rendered HTML plus extracted text.

  • API base URL: https://scraperapi.aicontent.engineering
  • Docs (this page): https://scraper.aicontent.engineering/docs
  • What it returns: raw HTML (post-JS), extracted text, HTTP status, title, timing
  • Stealth: every request runs through a fingerprint-correct, anti-detection browser stack - bot signatures, navigator quirks, and TLS fingerprints look like a real visitor.

For agents: the API host is scraperapi.aicontent.engineering. The marketing/docs host (scraper.aicontent.engineering) is a different server and does not serve /scrape. Hitting the wrong host returns a 404 or HTML page, not a useful error. If you ever see HTTP 401 with {"error":"unauthorized"} you are hitting the right host but missing or sending an invalid X-API-Key header.

Auth

Send your client key in the X-API-Key header. One key per consuming app.

Keys use the sc_ prefix. Existing keys with other prefixes keep working.

# Submit a job
curl -X POST https://scraperapi.aicontent.engineering/jobs \
  -H "X-API-Key: sc_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://example.com"]}'
# -> {"jobs":[{"id": 123, "url": "https://example.com", "status": "queued", ...}]}

# Poll
curl https://scraperapi.aicontent.engineering/jobs/123 -H "X-API-Key: sc_YOUR_KEY_HERE"
# -> {"id": 123, "status": "done", "tier_used": "datacenter", "screenshot_url": "...", ...}

Pricing & quotas

Line Cost Notes
Base subscription $99 / month Per client. Required. Covers platform access + support.
Stealth datacenter scrape 5 credits $0.008 each. Failed datacenter attempts are FREE - you're only charged if we returned usable content.
Residential proxy 25 credits total $0.040 each. Fires automatically when datacenter is blocked or the domain is known-hard. Charged whether the attempt succeeded or not (we pay for residential bandwidth either way).
Web Unblocker 50 credits total $0.080 each. Hosted fall-through for the hardest 1-2% of targets. Charged on attempt.
/fetch (binaries) 1 / 10 / 50 credits Per tier (datacenter/residential/web_unblocker). 80% cheaper than /scrape since there's no JS render.

Credits price at $0.0016 each. Usage is reported as Stripe meter events and rolls up monthly into the same invoice as the base subscription.

Billing principles:

  • A scrape of an easy site that succeeds on datacenter = 5 credits ($0.008).
  • A scrape that fails on datacenter and succeeds on residential = 25 credits ($0.040). Datacenter is free; you pay for the tier that worked.
  • A scrape that fails on every tier = 75 credits ($0.120). No charge for the free datacenter attempt; 25 for the residential attempt + 50 for the Web Unblocker attempt (we paid Rayobyte for both).

Local quota. Each client also has a monthly_credit_limit (default 100,000). When the month's usage would exceed it, the service returns 429 {"error":"monthly credit limit reached","limit":N,"used":N}. Reset is the 1st of each calendar month (UTC). The limit is enforced regardless of Stripe billing status.

Endpoints - async jobs (primary)

The canonical flow: POST jobs, get ids back, poll for results. Submission returns in well under a second regardless of how slow the targets are. The scraper runs the escalation ladder server-side; your client just polls.

POST /jobs

Submit one or more URLs for async processing.

Request body:

Field Type Required Default Notes
urls array of strings (or single string) yes - Up to 50 URLs. Each must have a scheme.
op string no "scrape" "scrape" = full browser render, returns html+text+screenshot. "fetch" = raw HTTP bytes (PDFs, images, JSON, anything), cheaper.
wait_ms int no 1500 scrape only - max wait after page DOM is ready. Adaptive polling for >200 chars body text up to max(wait_ms, 6000). Use to wait LONGER on slow SPAs.
max_bytes int no 52,428,800 (50MB) fetch only - body size cap. Maximum 50MB.

Response (200 - returns immediately, all jobs queued):

{
  "jobs": [
    {"id": 123, "url": "https://example.com", "domain": "example.com", "op": "scrape", "status": "queued"},
    {"id": 124, "url": "https://another.com/file.pdf", "domain": "another.com", "op": "fetch", "status": "queued"}
  ]
}

Pre-flight credit check uses the minimum tier cost. The 429 fires only when even the cheapest possible execution would exceed your monthly limit.

GET /jobs/{id}

Poll a single job's status. Returns the full result when status="done".

Response shape (status varies by lifecycle):

{
  "id": 123,
  "url": "https://example.com",
  "domain": "example.com",
  "op": "scrape",
  "status": "done",
  "tier_used": "datacenter",
  "status_code": 200,
  "blocked": false,
  "block_reason": null,
  "render_ms": 8643,
  "bytes_html": 12435,
  "credits": 5,
  "queued_at": "2026-05-30T00:52:08.50304+00:00",
  "expires_at": "2026-06-06T00:52:08.50304+00:00",
  "screenshot_url": "https://.../63.jpg",
  "title": "Example Domain",
  "html": "<!doctype html>...full rendered page...",
  "text": "Example Domain  This domain is for use in..."
}

For op="scrape" when done you get the full rendered page back directly - html (post-JS DOM), text (extracted body text from <main>/<article>/<body>), and title. No need to re-scrape to get content. (Note: html/text can be large; the list endpoint below omits them - poll the specific id when you want the body.)

For op="fetch" when done, the response also has:

{
  "content_type": "image/jpeg",
  "bytes": 8001,
  "body_url": "https://qiesrihozwimcxcwsugy.supabase.co/storage/v1/object/sign/scraper-fetched/...?token=..."
}

body_url is a Supabase Storage signed URL valid for 1 hour. Fetch it with a plain GET (no auth needed on that URL) to get the raw bytes. Re-poll /jobs/{id} later to mint a fresh URL if needed.

Lifecycle:

status Meaning
queued Submitted, awaiting a worker.
running A worker picked it up; render or fetch in flight.
done Terminal - success. Result fields populated.
failed Terminal - all escalation tiers failed. error and block_reason describe what happened.
cancelled You called DELETE /jobs/{id} before it ran.
stale A worker started it but never finished within 5 minutes (presumed crashed). Treat as failed for retry purposes.

Polling cadence: scrapes typically complete in 5-30s. A 2s poll interval is reasonable.

GET /jobs?ids=1,2,3,...

Bulk poll up to 100 ids in one shot. Returns the same per-job shape as /jobs/{id}, filtered to jobs owned by your key.

curl "https://scraperapi.aicontent.engineering/jobs?ids=123,124,125" \
  -H "X-API-Key: sc_..."
# -> { "jobs": [...] }

GET /jobs (no ids) - list ALL your jobs

Omit ids and you get every job owned by your key, newest first. This is how you recover ids after a crashed or interrupted batch run - you don't have to have saved the ids at submit time. List rows are lightweight: they include status, title, byte counts, and the screenshot URL, but not html/text/body_url (poll the specific id for content).

Filters (all optional):

Param Meaning
status queued, running, done, failed, cancelled, stale - comma-separated for multiple.
op scrape or fetch.
limit Page size, default 50, max 200.
before_id Cursor: return jobs with id < before_id. Use the next_before_id from the previous page.
# Everything still in flight
curl "https://scraperapi.aicontent.engineering/jobs?status=queued,running" -H "X-API-Key: sc_..."

# Page through completed scrapes, 200 at a time
curl "https://scraperapi.aicontent.engineering/jobs?status=done&op=scrape&limit=200" -H "X-API-Key: sc_..."
# -> { "jobs": [...], "count": 200, "next_before_id": 4021 }
curl "https://scraperapi.aicontent.engineering/jobs?status=done&op=scrape&limit=200&before_id=4021" -H "X-API-Key: sc_..."

next_before_id is null on the last page.

Retention - content is auto-deleted after 7 days

Completed jobs (and their stored html/text/screenshots/fetch-bodies) are kept for 7 days, then a background sweep deletes the row and its storage objects. Each job carries an expires_at timestamp so you know the window. Pull what you need within 7 days of completion - after that the job is gone and you'd re-scrape (and re-pay). There's no extra charge for storage during the window.

DELETE /jobs/{id}

Cancel a job. Only works while status="queued" - a running job can't be cancelled mid-render. Returns 409 if the job is already running or terminal.

GET /health, GET /openapi.json

No auth. Liveness probe + machine-readable spec.

Errors

HTTP When
400 Missing url, malformed JSON, batch >50 URLs, max_bytes out of range.
401 Missing or invalid X-API-Key.
421 You hit scraper.aicontent.engineering (marketing host) instead of scraperapi.aicontent.engineering (API host). Body has api_base.
429 Monthly credit limit reached.
502 All escalation tiers tried and failed. Body has attempts[] array detailing each attempt.

Retry guidance: retry transient 502s once with backoff. Don't retry 401 (key issue) or 429 (need limit increase).

TypeScript client

const SCRAPER_URL = 'https://scraperapi.aicontent.engineering'
const SCRAPER_KEY = process.env.SCRAPER_API_KEY!   // sc_...

type Job = {
  id: number
  url: string
  domain: string
  op: 'scrape' | 'fetch'
  status: 'queued' | 'running' | 'done' | 'failed' | 'cancelled' | 'stale'
  tier_used?: 'datacenter' | 'residential' | 'web_unblocker'
  status_code?: number
  blocked?: boolean
  block_reason?: string | null
  render_ms?: number
  bytes_html?: number
  bytes?: number
  content_type?: string
  body_url?: string         // fetch op only; 1-hour signed URL
  screenshot_url?: string   // scrape op only
  credits?: number
  error?: string
}

const POLL_INTERVAL_MS = 2000
const POLL_TIMEOUT_MS = 5 * 60 * 1000

async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`${SCRAPER_URL}${path}`, {
    ...init,
    headers: { 'Content-Type': 'application/json', 'X-API-Key': SCRAPER_KEY, ...(init.headers || {}) },
  })
  if (res.status === 401) throw new Error('scraper: invalid key')
  if (res.status === 429) throw new Error('scraper: quota exceeded')
  const data = await res.json()
  if (!res.ok) throw new Error(data?.error || `scraper: HTTP ${res.status}`)
  return data as T
}

// Submit one or more URLs. Returns the job ids immediately (status=queued).
export async function submitJobs(urls: string[], op: 'scrape' | 'fetch' = 'scrape'): Promise<Job[]> {
  const { jobs } = await api<{ jobs: Job[] }>('/jobs', {
    method: 'POST',
    body: JSON.stringify({ urls, op }),
  })
  return jobs
}

// Poll a single job until it reaches a terminal state.
export async function waitForJob(id: number): Promise<Job> {
  const start = Date.now()
  while (true) {
    const job = await api<Job>(`/jobs/${id}`)
    if (['done', 'failed', 'cancelled', 'stale'].includes(job.status)) return job
    if (Date.now() - start > POLL_TIMEOUT_MS) throw new Error(`scraper: job ${id} timed out`)
    await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
  }
}

const chunk = <T>(a: T[], n: number): T[][] =>
  Array.from({ length: Math.ceil(a.length / n) }, (_, i) => a.slice(i * n, i * n + n))

// List YOUR jobs (newest first). Recover ids after a crash, or sweep for unfinished work.
// Omit `status` for everything. Pages via before_id automatically.
export async function listMyJobs(
  opts: { status?: string; op?: 'scrape' | 'fetch'; max?: number } = {}
): Promise<Job[]> {
  const out: Job[] = []
  let before: number | undefined
  while (out.length < (opts.max ?? Infinity)) {
    const q = new URLSearchParams({ limit: '200' })
    if (opts.status) q.set('status', opts.status)
    if (opts.op) q.set('op', opts.op)
    if (before) q.set('before_id', String(before))
    const r = await api<{ jobs: Job[]; next_before_id: number | null }>(`/jobs?${q}`)
    out.push(...r.jobs)
    if (r.next_before_id == null) break
    before = r.next_before_id
  }
  return out
}

// Convenience: submit + wait. CHUNKS correctly - submit in 50s, poll in 100s - so it
// scales to thousands of URLs. This is the right way to run a large batch.
export async function scrapeAll(urls: string[], op: 'scrape' | 'fetch' = 'scrape'): Promise<Job[]> {
  // 1. Submit in chunks of 50 (the POST limit), collect all ids.
  const queued: Job[] = []
  for (const c of chunk(urls, 50)) queued.push(...await submitJobs(c, op))

  // 2. Poll in chunks of 100 (the bulk-poll limit) until every job is terminal.
  const pending = new Map(queued.map(j => [j.id, j]))
  const done: Job[] = []
  const start = Date.now()
  while (pending.size > 0) {
    if (Date.now() - start > POLL_TIMEOUT_MS) throw new Error('scraper: batch timed out')
    await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
    for (const c of chunk([...pending.keys()], 100)) {
      const { jobs } = await api<{ jobs: Job[] }>(`/jobs?ids=${c.join(',')}`)
      for (const j of jobs) {
        if (['done', 'failed', 'cancelled', 'stale'].includes(j.status)) {
          done.push(j); pending.delete(j.id)
        }
      }
    }
  }
  return done
}

// Example: scrape one URL the simple way
const [job] = await submitJobs(['https://example.com'])
const result = await waitForJob(job.id)
if (result.status === 'done') {
  console.log(result.title, result.text?.slice(0, 200))  // content comes back inline
}

// Example: run a 660-URL batch reliably (chunked submit + chunked poll, retry failures)
const results = await scrapeAll(allUrls)              // handles chunking internally
const failed = results.filter(j => j.status !== 'done').map(j => j.url)
if (failed.length) await scrapeAll(failed)            // one retry pass

Python client

import os, time, requests
from typing import Iterable, Literal

SCRAPER_URL = 'https://scraperapi.aicontent.engineering'
KEY = os.environ['SCRAPER_API_KEY']
HEADERS = {'X-API-Key': KEY, 'Content-Type': 'application/json'}

POLL_INTERVAL_S = 2
POLL_TIMEOUT_S = 5 * 60


def submit_jobs(urls: Iterable[str], op: Literal['scrape', 'fetch'] = 'scrape') -> list[dict]:
    r = requests.post(f'{SCRAPER_URL}/jobs', headers=HEADERS, json={'urls': list(urls), 'op': op}, timeout=15)
    r.raise_for_status()
    return r.json()['jobs']


def wait_for_job(job_id: int) -> dict:
    deadline = time.time() + POLL_TIMEOUT_S
    while True:
        r = requests.get(f'{SCRAPER_URL}/jobs/{job_id}', headers={'X-API-Key': KEY}, timeout=15)
        r.raise_for_status()
        job = r.json()
        if job['status'] in ('done', 'failed', 'cancelled', 'stale'):
            return job
        if time.time() > deadline:
            raise RuntimeError(f'scraper: job {job_id} timed out')
        time.sleep(POLL_INTERVAL_S)


def _chunks(seq, n):
    for i in range(0, len(seq), n):
        yield seq[i:i + n]


def list_my_jobs(status: str = '', op: str = '', max_rows: int | None = None) -> list[dict]:
    """List YOUR jobs, newest first. Recover ids after a crash, or find unfinished work.
    Pass status='queued,running' for in-flight only. Pages via before_id automatically."""
    out, before = [], None
    while max_rows is None or len(out) < max_rows:
        params = {'limit': 200}
        if status: params['status'] = status
        if op: params['op'] = op
        if before: params['before_id'] = before
        r = requests.get(f'{SCRAPER_URL}/jobs', headers={'X-API-Key': KEY}, params=params, timeout=15)
        r.raise_for_status()
        body = r.json()
        out.extend(body['jobs'])
        before = body.get('next_before_id')
        if before is None:
            break
    return out


def scrape_all(urls: list[str], op: Literal['scrape', 'fetch'] = 'scrape') -> list[dict]:
    """Submit + wait, CHUNKED correctly (50 per POST, 100 per poll) so it scales to
    thousands of URLs without hitting any per-request limit. The right way to run a batch."""
    # 1. Submit in chunks of 50, collect ids.
    pending = {}
    for c in _chunks(urls, 50):
        for j in submit_jobs(c, op):
            pending[j['id']] = j
    # 2. Poll in chunks of 100 until everything is terminal.
    done, deadline = [], time.time() + POLL_TIMEOUT_S
    while pending:
        if time.time() > deadline:
            raise RuntimeError('scraper: batch timed out')
        time.sleep(POLL_INTERVAL_S)
        for c in _chunks(list(pending), 100):
            ids = ','.join(str(i) for i in c)
            r = requests.get(f'{SCRAPER_URL}/jobs', headers={'X-API-Key': KEY}, params={'ids': ids}, timeout=15)
            r.raise_for_status()
            for j in r.json()['jobs']:
                if j['status'] in ('done', 'failed', 'cancelled', 'stale'):
                    done.append(j)
                    pending.pop(j['id'], None)
    return done


# Example: run a large batch reliably, then retry only the failures.
results = scrape_all(all_urls)                      # chunks internally
for j in (r for r in results if r['status'] == 'done' and r['op'] == 'scrape'):
    save(j['url'], j['html'], j['text'])            # content comes back inline
failed = [r['url'] for r in results if r['status'] != 'done']
if failed:
    scrape_all(failed)                              # one retry pass

# Example: recover after a crash - find everything still running and wait on it
in_flight = list_my_jobs(status='queued,running')
print(f'{len(in_flight)} jobs still in flight; their ids:', [j['id'] for j in in_flight])

# Example: fetch a PDF, then download the bytes via the signed URL
[fetch_job] = submit_jobs(['https://example.com/file.pdf'], op='fetch')
done = wait_for_job(fetch_job['id'])
if done['status'] == 'done':
    pdf_bytes = requests.get(done['body_url'], timeout=60).content
    open('out.pdf', 'wb').write(pdf_bytes)

Legacy synchronous endpoints

Kept for back-compatibility with integrations that pre-date the async API. Do not use these for new integrations - long-running calls can hit Cloudflare's 100-second tunnel timeout on slow targets. Use POST /jobs instead.

Method Path Behavior
POST /scrape Synchronous scrape. Held connection. Returns the same shape as done job result, plus an attempts[] array.
POST /scrape-batch Synchronous batch (≤50 URLs). All-or-nothing latency: returns when every URL has been attempted.
POST /fetch Synchronous fetch - body returned inline with original Content-Type (no body_url since the connection itself streams the bytes).

These pre-date the async API and use the same auth, same escalation ladder, same billing.

Admin SQL

Until the /admin/scraper UI ships, manage clients with psql against the aicontent Supabase pooler.

Internal table names use scraper_* (renamed from the original render_* on 2026-05-28).

-- Issue a new client. Generate the raw key first:
--   KEY="sc_$(openssl rand -base64 24 | tr '+/=' '_-')"
--   echo $KEY                              # hand to the client
--   PREFIX=$(echo $KEY | cut -c1-8)
--   HASH=$(printf '%s' "$KEY" | shasum -a 256 | awk '{print $1}')
INSERT INTO scraper_api_clients (name, contact_email, key_prefix, key_hash, monthly_credit_limit)
VALUES ('ClientName', 'contact@client.com', '<PREFIX>', '<HASH>', 100000);

-- Wire metered billing (after creating Stripe customer + subscription):
UPDATE scraper_api_clients
SET stripe_customer_id = 'cus_...',
    stripe_subscription_id = 'sub_...',
    stripe_subscription_item_id = 'si_...',
    stripe_billing_enabled = TRUE
WHERE name = 'ClientName';

-- Bump or remove a credit limit
UPDATE scraper_api_clients SET monthly_credit_limit = 500000 WHERE name = 'ClientName';
UPDATE scraper_api_clients SET monthly_credit_limit = 0 WHERE name = 'ClientName';  -- 0 = unlimited

-- Disable a key (auth fails immediately)
UPDATE scraper_api_clients SET status = 'disabled' WHERE name = 'ClientName';

-- Current month usage per client
SELECT c.name,
       scraper_client_credits_used_this_month(c.id) AS used,
       c.monthly_credit_limit AS limit,
       c.stripe_billing_enabled
FROM scraper_api_clients c
WHERE c.status = 'active';

-- Which scrapes were NOT reported to Stripe? (success+billing_enabled+not reported)
SELECT u.occurred_at, u.url, u.credits_charged
FROM scraper_usage u
JOIN scraper_api_clients c ON c.id = u.client_id
WHERE c.stripe_billing_enabled = TRUE
  AND u.success = TRUE
  AND u.stripe_event_reported = FALSE
ORDER BY u.occurred_at DESC
LIMIT 50;

Stripe references

Object ID Notes
Product (metered usage) prod_UbL9M1ahNphKFu AiContent Scraper credits
Product (base sub) prod_UbNSKT4EQCJegl $99/mo platform fee
Meter mtr_test_61UlWQqaFHOMIlMTd41PFJueV2quz1ua event_name render_credit, display "AiContent Scraper Credits"
Price (base sub) price_1TcAhCPFJueV2quzYCKlIguD $99/month, licensed, recurring
Price (metered credits) price_1TcAyHPFJueV2quzE0yGPYjK $0.0016/credit, metered, recurring

Preview a customer's upcoming invoice:

curl -u "$STRIPE_SECRET_KEY:" -X POST \
  https://api.stripe.com/v1/invoices/create_preview \
  -d subscription=sub_...