REST API & Custom Server
SpyWeb runs a built-in web server on 127.0.0.1:7979 serving the dashboard UI, a built-in JSON API, and any custom endpoints you define.
1. Built-in REST API
Section titled “1. Built-in REST API”SpyWeb provides default endpoints for interacting with your jobs and records.
| Endpoint | Description |
|---|---|
GET / |
HTML records viewer (dashboard) |
GET /api/records?job_id=<id> |
JSON records for a job |
GET /api/records?job_id=<id>&limit=50&after=<timestamp> |
Paginated records |
GET /api/jobs |
List all configured jobs |
/api/v/{name} |
Custom endpoints — requires auth (defined in server/init.lua) |
/api/public/{name} |
Custom endpoints — always public, no auth required (defined in server/init.lua) |
Authentication
Section titled “Authentication”If SPYWEB_API_KEY is set, all /api/* endpoints require the X-SpyWeb-Key header — except /api/public/* routes, which are always accessible without a key:
curl -H "X-SpyWeb-Key: your_secret_key" http://127.0.0.1:7979/api/jobscurl http://127.0.0.1:7979/api/public/healthBring Your Own UI
Section titled “Bring Your Own UI”The default dashboard is in ui/index.html. SpyWeb serves this file dynamically from disk. Build your own with Vue, React, Svelte, or vanilla JS, as long as your build outputs index.html into ui/, SpyWeb serves it instantly with no restart.
2. Webhook Payloads
Section titled “2. Webhook Payloads”When the engine finishes a cycle and has new items, it can push them to a webhook. The default payload looks like this:
{ "job_name": "Product Tracker", "item_count": 2, "items": [ { "title": "Item A", "price": "$49.99", "link": "https://example.com/a", "keywords": ["sale"] } ]}Use before_webhook(payload, ctx) to reshape this JSON before it is sent, useful for Discord embeds, Slack blocks, or Pushover.
3. Programmable API Server
Section titled “3. Programmable API Server”SpyWeb lets you define custom HTTP endpoints via Lua. Create a server/init.lua file in your project root.
Quick Start
Section titled “Quick Start”Create server/init.lua:
function get:hello() return { status = 200, body = "Hello from SpyWeb!" }end
function post:echo() return { status = 200, body = self.body, headers = { ["Content-Type"] = "text/plain" } }endStart SpyWeb and hit your endpoints:
./spyweb startcurl http://127.0.0.1:7979/api/v/hellocurl -X POST -d "test body" http://127.0.0.1:7979/api/v/echoHow It Works
Section titled “How It Works”Each HTTP request to /api/v/{name} (or /api/public/{name} for unauthenticated endpoints):
- Reads
server/init.luafrom disk (with hot-reload) - Creates a fresh Lua VM for the request
- Loads the script, defining route handlers
- For
/api/v/{name}, looks upget["name"]→post["name"]→all["name"]. For/api/public/{name}, looks uppublic.get["name"]→public.post["name"]→public.all["name"] - Calls the handler with
selfcarrying the request context - Returns the value as an HTTP response
- Drops the VM, so no shared state between requests
Route Registration
Section titled “Route Registration”Private endpoints (require X-SpyWeb-Key if SPYWEB_API_KEY is set):
function get:users() -- handles GET /api/v/usersend
function post:users() -- handles POST /api/v/usersend
function all:ping() -- handles any method on /api/v/pingendPublic endpoints (always accessible without a key):
function public.get:status() -- handles GET /api/public/statusend
function public.post:webhook() -- handles POST /api/public/webhookend
function public.all:catchall() -- handles any method on /api/public/catchallendThe method tables get, post, put, patch, delete, all, public.get, public.post, public.put, public.patch, public.delete, and public.all are pre-injected.
Functions defined outside method tables are private helpers, never exposed as endpoints:
function validate_input(data) return data and data.nameend
function get:users() local users = fetch_users() return { status = 200, body = users }endURL Structure
Section titled “URL Structure”Auth-protected endpoints:
/api/v/{name}/{path_args...}Public endpoints (no key required):
/api/public/{name}/{path_args...}Each prefix resolves from separate handler tables — a handler defined under get.* is only reachable via /api/v/, and a handler defined under public.get.* is only reachable via /api/public/.
| URL | Handler | self.path_args |
|---|---|---|
/api/v/users |
get.users |
{} |
/api/v/users/123 |
get.users |
{"123"} |
/api/v/users/123/profile |
get.users |
{"123", "profile"} |
/api/public/status |
public.get.status |
{} |
/api/public/status/db |
public.get.status |
{"db"} |
Request Context (self)
Section titled “Request Context (self)”| Field | Type | Description |
|---|---|---|
self.body |
string or nil | Raw request body (POST, PUT, PATCH); silently truncated at 10MB |
self.method |
string | HTTP method |
self.path |
string | Full request path |
self.path_args |
table | Array of trailing path segments |
self.query |
table | URL query parameters |
self.headers |
table | Request headers (lowercase keys) |
self.client_ip |
string | Client IP address |
Response Contract
Section titled “Response Contract”Table (full control)
return { status = 200, -- HTTP status (clamped to 100-599) body = "Hello!", -- string, table (auto-JSON), number, boolean, or binary headers = { -- optional extra headers ["X-Custom"] = "value" }}Body types:
| Lua Type | Response |
|---|---|
| string | Raw body as-is |
| table | Auto-encoded as JSON with Content-Type: application/json |
| number | Converted to string |
| boolean | Converted to "true" or "false" |
| nil | Empty body |
String (quick response)
return "Hello, World!"-- Equivalent to: { status = 200, body = "Hello, World!" }Available Globals
Section titled “Available Globals”The server VM has the same globals as scraper hooks (HTTP client, storage, database, file I/O, and utilities.
Defer (Async Cleanup)
Unlike defer() in pipeline hooks, the server’s defer() supports async bindings.
Deferred functions run in reverse order (last registered runs first). Each function runs sequentially in the same Lua VM, so they share globals and can safely access self via closure capture. Errors are logged but don’t affect other deferred functions.
function get:resource() defer(function() sleep(1000) log("request finished for " .. self.path) http_post("https://hooks.example.com/callback", json_encode({ status = "done" }), { ["Content-Type"] = "application/json" }) end) return { status = 200, body = { message = "ok" } }endExamples
Section titled “Examples”Robust JSON API
Section titled “Robust JSON API”function post:items() local data, err = json_decode(self.body or "") if not data or type(data) ~= "table" then return { status = 400, body = { error = "Invalid JSON payload: " .. tostring(err) } } end if not data.name then return { status = 400, body = { error = "Missing 'name' field" } } end return { status = 201, body = { message = "Created", item = data } }endServe Scraped Data
Section titled “Serve Scraped Data”function get:latest_prices() local prices = global_store_get("latest_prices") if not prices then return { status = 404, body = "No data yet" } end return { status = 200, body = prices, headers = { ["Content-Type"] = "application/json" } }endProxy to External API
Section titled “Proxy to External API”function get:weather() local city = self.query.city or "London" local res, err = http_get("https://api.weather.com/v1/" .. city) if not res then return { status = 502, body = { error = err } } end return { status = res.status, body = res.body, headers = { ["Content-Type"] = "application/json" } }end
function post:webhook() local payload = json_decode(self.body) http_post("https://hooks.example.com/relay", json_encode(payload), { ["Content-Type"] = "application/json" }) return { status = 200, body = "Relayed" }endHealth Check
Section titled “Health Check”-- Private health check (/api/v/health)function all:health() return { status = 200, body = "ok" }end
-- Public health check (/api/public/health, no key required)function public.all:health() return { status = 200, body = "ok" }endError Handling
Section titled “Error Handling”If a handler throws a Lua error:
- The error is logged to the terminal
- The error is appended to
server/error.logwith a timestamp - The client receives
{"error": "Internal Server Error"}with status 500
If server/init.lua has a syntax error, all requests fail with 500 until the file is fixed.
Timeout
Section titled “Timeout”With Luau (the default), each request has a 30-second timeout. If a handler runs longer, it is forcefully terminated and the client receives a 500 error with "Script execution timed out".
The timeout is enforced via Luau’s bytecode interrupt hook, so no threads are leaked.
Note: Timeout is only available with Luau. Lua 5.4 builds do not have timeout protection.
Serving Binary Data
Section titled “Serving Binary Data”Use fs_read_binary() to serve images, PDFs, or other assets from server/ or shared/:
function get:logo() local data = fs_read_binary("logo.png") if not data then return { status = 404, body = "Not Found" } end return { status = 200, body = data, headers = { ["Content-Type"] = "image/png" } }endAvailable Lua Globals
Section titled “Available Lua Globals”The server VM has access to the same globals as scraper hooks:
| Category | Functions |
|---|---|
| File I/O | fs_read(filename), fs_read_binary(filename), fs_append(filename, content), fs_overwrite(filename, content), log(message) |
| HTTP Client | http_get(url, [headers]), http_post(url, body, [headers]), http_request({...}), http_multipart(url, fields, [headers]) |
| Storage | global_store_set(key, value), global_store_get(key), global_store_incr(key, default, delta), global_store_delete(key) |
| Database (SQLite) | db_query(sql, [params]), db_exec(sql, [params]) |
| Utilities | json_encode(value), json_decode(string), env_get(key), defer(fn), sleep(ms), notify(title, body, [timeout]) |
File writes go to
server/by default. Useshared/prefix (e.g.fs_overwrite("shared/data.json", ...)) to write to the shared folder. Reads scanserver/first, then fall back toshared/.
Authentication
Section titled “Authentication”The API server reuses SpyWeb’s existing authentication. If SPYWEB_API_KEY is set, all /api/* routes require the X-SpyWeb-Key header — except /api/public/* routes, which are always accessible without a key:
# Requires keycurl -H "X-SpyWeb-Key: your_key" http://127.0.0.1:7979/api/v/hello
# Always public, no key neededcurl http://127.0.0.1:7979/api/public/healthWithout the header on a protected route, the server returns 401 Unauthorized.
Public endpoints are defined under the public.* namespace in init.lua — they are never reachable via /api/v/:
-- Private (requires key)function get:status() return { status = 200, body = "ok" }end
-- Public (no key required)function public.get:health() return { status = 200, body = "ok" }endEnvironment Variables
Section titled “Environment Variables”| Variable | Default | Description |
|---|---|---|
SPYWEB_PORT |
7979 |
Server port |
SPYWEB_API_KEY |
none | API authentication key |
SPYWEB_DISABLE_SERVER |
none | Set to any value to skip starting the web server |
SPYWEB_LOG |
info |
Log verbosity: info (all), warn (warnings+errors), error (errors only) |
The server can also be configured via CLI flags on spyweb start:
| Flag | Short | Description |
|---|---|---|
--port <PORT> |
Customize the server port (default 7979) | |
--no-server |
-n |
Start without the web server |
--quiet |
-q |
Suppress info logs (use -qq for errors only) |
Data Directory
Section titled “Data Directory”The server’s fs_* operations are scoped to the server/ directory:
Directoryproject/
Directoryserver/
- init.lua Route definitions
- error.log Error logs (auto-created)
Directorydata/ Persistent data
- …
Directoryshared/ Cross-VM shared data
- …
- jobs.toml
- data Scraper database
Directoryui/ Dashboard files
- …
Troubleshooting
Section titled “Troubleshooting”All endpoints return 500: Check server/error.log for syntax or runtime errors.
Endpoints return 404: Verify the handler is in the correct method table for the URL prefix — /api/v/{name} uses get.{name}, post.{name}, etc.; /api/public/{name} uses public.get.{name}, public.post.{name}, etc. The all (or public.all) table is checked last.
Body is empty or truncated: Request body is capped at 10MB.
Timeout errors: Luau-only 30-second timeout per request. Check for infinite loops in handler code. Move heavy computation to background tasks using defer.