Browser automation on NativePort splits into two shapes, and picking the wrong one is the most common way to overpay or overbuild. A one-shot call sends a URL, gets back a page’s content, a screenshot, or a PDF, and the browser behind it is gone before the response lands — no state to manage, no session to clean up. A stateful session hands you a real, persistent browser to drive yourself with Playwright or Puppeteer: logins, multi-step interactions, and anything that has to remember what happened on the previous page. Steel and Browserless cover the first shape on NativePort; Browserbase covers the second. This guide walks through both, with runnable examples against each.
What you’ll need
- A NativePort API key. Sign up to get a key and $5 in credits.
- Python 3, standard library only for the one-shot examples —
urllib.request, nopip install. - Playwright (
pip install playwright && playwright install chromium) for the Browserbase session example. - Your key exported as an environment variable, never hardcoded:
export NATIVEPORT_API_KEY="np_..."
One-shot: Steel and Browserless
Both proxy a fixed, closed set of single-call actions — no query string configuration, no session to keep alive, one page load per call. Neither accepts arbitrary fields: send something outside each route’s allow-list and the gateway rejects it with a 400 before it ever reaches the upstream, so a typo in a field name fails fast and cheap.
Steel: scrape, screenshot, pdf
Steel’s three actions take a JSON body and nothing else — no query parameters at all are permitted, not even ones Steel itself would otherwise accept.
import json
import os
import urllib.request
GATEWAY = "https://api.nativeport.ai"
API_KEY = os.environ["NATIVEPORT_API_KEY"]
def steel(action, body):
data = json.dumps(body).encode()
req = urllib.request.Request(f"{GATEWAY}/steel/{action}", data=data, method="POST")
req.add_header("Authorization", f"Bearer {API_KEY}")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read()
# Markdown content of a page, no screenshot or PDF along with it
status, body = steel("scrape", {"url": "https://example.com", "format": ["markdown"]})
print(status, json.loads(body)["result"]["content"]["markdown"][:200])
# A full-page screenshot
status, body = steel("screenshot", {"url": "https://example.com", "fullPage": True})
# A rendered PDF
status, body = steel("pdf", {"url": "https://example.com"})
The body field each endpoint accepts is closed and endpoint-specific: url and delay (0–10000ms) work on all three; scrape additionally takes format (an array of html, cleaned_html, markdown, readability); screenshot additionally takes fullPage; pdf takes nothing extra. Anything else — including Steel’s own useProxy field — comes back as a 400:
{"error": "Rejected: body field \"useProxy\" is not permitted."}
Steel bills a flat $0.005 per accepted, forwarded call, charged the moment the gateway commits to forwarding — not metered by page weight or render time.
Browserless: content, screenshot, pdf, scrape
Browserless’s four REST actions work the same way, one extra layer of nuance: every call also gets a forced 30-second bound, applied server-side, so nothing you send in the body can push a single call past it.
def browserless(action, body):
data = json.dumps(body).encode()
req = urllib.request.Request(f"{GATEWAY}/browserless/{action}", data=data, method="POST")
req.add_header("Authorization", f"Bearer {API_KEY}")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=35) as resp:
return resp.status, resp.read()
# Rendered HTML after JS execution
status, body = browserless("content", {"url": "https://example.com"})
# A screenshot, Puppeteer-style options nested under "options"
status, body = browserless("screenshot", {"url": "https://example.com", "options": {"fullPage": True, "type": "png"}})
# A PDF
status, body = browserless("pdf", {"url": "https://example.com", "options": {"format": "A4"}})
# A structured, CSS-selector scrape — "elements" is required here
status, body = browserless("scrape", {"url": "https://example.com", "elements": [{"selector": "h1"}]})
The common body fields (url, html, gotoOptions, waitForSelector, waitForFunction, waitForEvent, rejectResourceTypes, rejectRequestPattern, bestAttempt) work on all four; screenshot and pdf additionally take options; scrape additionally takes elements. You never need to add token or timeout yourself — NativePort injects the Browserless credential and the 30-second bound server-side, so any query string you send is policed the same way the body is: only token and timeout are recognized at all, and both are overwritten regardless of what you pass. Browserless bills a flat $0.004 per accepted call, precharged before the fetch — Browserless itself bills by session duration, on failures too, so NativePort charges up front rather than waiting for a response that might never settle cleanly.
Steel or Browserless?
Both do the same four jobs (three for Steel, since content/scrape are split differently). The practical differences: Browserless’s scrape action returns structured, per-element JSON (attributes, text, position) from CSS selectors in one call, which Steel’s format array doesn’t do — Steel gives you the whole page as HTML/Markdown/cleaned text, not per-element extraction. Browserless also enforces the hard 30-second bound; Steel’s only timing knob is the delay field. Reach for Browserless’s scrape when you know the exact elements you want; reach for Steel when you want the whole page’s content in one of four text formats, or a screenshot/PDF with no extra configuration to get wrong. Neither is the pick for anything spanning more than one page load, a login, or state that has to persist between calls — that’s what a session is for.
Stateful: a Browserbase session driven by Playwright
Browserbase doesn’t return content — it returns a browser. NativePort’s route creates, checks the status of, and releases a session; the actual Playwright/Puppeteer traffic runs directly between your process and Browserbase over the session’s own WebSocket URL, never through the gateway. That matters for two reasons: the gateway never sees your automation script or the pages it visits, and nothing about session length changes what the gateway bills you, since it only meters the three lifecycle calls (create, status, release), not the browser-hours Browserbase itself tracks over that WebSocket.
import json
import os
import urllib.request
from playwright.sync_api import sync_playwright
GATEWAY = "https://api.nativeport.ai"
API_KEY = os.environ["NATIVEPORT_API_KEY"]
def _request(method, path, body=None):
data = json.dumps(body).encode() if body is not None else b""
req = urllib.request.Request(f"{GATEWAY}{path}", data=data, method=method)
req.add_header("Authorization", f"Bearer {API_KEY}")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
# 1. Create a session through the gateway (the shared Browserbase credential
# never leaves NativePort's Worker)
session = _request("POST", "/browserbase/v1/sessions", {})
session_id = session["id"]
connect_url = session["connectUrl"]
# 2. Connect Playwright DIRECTLY to Browserbase's connectUrl — this leg does
# not go through the NativePort gateway at all
try:
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(connect_url)
page = browser.new_page()
page.goto("https://example.com")
title = page.title()
browser.close()
finally:
# 3. Always release the session through the gateway, even on failure —
# an unreleased session keeps accruing Browserbase-side browser-hours
# until it hits its own timeout
_request("POST", f"/browserbase/v1/sessions/{session_id}", {"status": "REQUEST_RELEASE"})
print(title)
The try/finally isn’t decoration — a session left open runs until its own timeout (Browserbase defaults this per-project; you can also pass a timeout in seconds when creating the session, from 60 up to 21,600), so always release it as soon as your script is done with it, success or failure. POST /browserbase/v1/sessions/{id} with {"status": "REQUEST_RELEASE"} is exactly the request/response half of that; nothing about ending a session touches the WebSocket leg you drove Playwright over.
NativePort mounts three Browserbase calls: create a session, check the status of one you created, and release it. It deliberately does not mount the session list endpoint (GET /v1/sessions with no ID) or the projects/contexts/extensions surface — those sit on the same shared account credential as every other NativePort tenant’s sessions, so exposing them would let one caller enumerate or read another’s. A GET to /browserbase/v1/sessions (no ID) or any of those other paths gets a 404, not a permissions error — from the gateway’s point of view they simply don’t exist as a route.
Errors you’ll actually hit
402 Payment Required: the NativePort balance is at $0. Every request answers this deterministically, so top up and retry.401 Unauthorized: theAuthorization: Bearer <NATIVEPORT_API_KEY>header is missing, malformed, or doesn’t resolve to an account.403 Forbidden: the key is valid but the account isn’t active.400from Steel or Browserless, body{"error": "Rejected: ..."}: a query parameter or JSON body field outside that endpoint’s allow-list. This is a gateway-side policy rejection — the request never reaches the upstream, so it’s never billed.404on any Browserbase path outside session create/status/release, or any Steel/Browserless action outside the four listed above: not rate-limited or degraded, just not a mounted route.- A slow or hanging Browserbase session: check its status with
GET /browserbase/v1/sessions/{id}before assuming something’s stuck, and release it regardless of what you find — a forgotten session doesn’t stop running on its own until its timeout.
Security
Treat NATIVEPORT_API_KEY like any other credential: environment variable or secret store, never a literal string in source, never logged. None of Steel’s, Browserless’s, or Browserbase’s own account credentials ever reach your process — the gateway injects each one server-side, and Browserbase’s connectUrl is scoped to the one session it was issued for, not the shared account key.
What this costs
Steel and Browserless bill flat per accepted call — $0.005 and $0.004 respectively — charged the moment the gateway commits to forwarding, whether or not the render succeeds. Browserbase’s three lifecycle calls (create, status, release) bill a flat rate per REST call through the gateway; the actual browser-hours your session runs are Browserbase’s own metering, not something the gateway meters separately to you beyond those calls. None of this carries a NativePort markup. Current per-call pricing lives on the Steel, Browserless and Browserbase provider pages. A zero balance answers every request with 402 instead of degrading. Adding credit carries a flat 5.5% fee on top-ups between $10 and $5,000 — never on the calls themselves. Full breakdown: pricing.
Where to go next
- Steel, Browserless and Browserbase provider pages: current pricing and benchmark standing.
- Browser actions leaderboard, screenshots leaderboard and agentic browsing leaderboard: how these routes and others score against the field.
- How to scrape a JavaScript-rendered page on NativePort: a one-shot alternative for a specific job — pulling clean, rendered content out of a JS-heavy page without a screenshot or PDF in the mix.
- Pricing: the full billing model.