hoody-curl turns HTTP requests into HTTP endpoints. It executes any HTTP request via a simple GET call, wraps complex POST operations into shareable URLs, schedules recurring requests with cron, and persists cookie sessions automatically. The request engine is libcurl, driven through Rust bindings.
Capabilities
Section titled “Capabilities”- POST→GET wrapping: turn any POST request into a shareable GET URL.
- Multiplexed WebSocket channel: pay the TCP/TLS handshake once and run hundreds of concurrent cURLs over one socket.
- Server-sent events (SSE): detect an upstream
text/event-streamresponse and forward events as they arrive, whether the upstream is OpenAI, Anthropic, or your own AI agent. - Scheduled requests: run recurring HTTP calls on a cron schedule.
- Session management: persist cookies across multiple requests automatically.
- Async execution: queue long-running requests and retrieve results later.
- Response storage: save responses to the
downloads/by-job/{job_id}/storage tree. - Advanced options: auth, retries, timeouts, cookies, redirects, and speed limits. Two groups are deliberately rejected with a 400: the proxy fields (
proxy,proxy_user,proxy_password), because libcurl would reach the proxy before URL validation could inspect it, and the certificate fields (cacert,cert,key), because they take filesystem paths that could be used to probe local files. - TypeScript SDK: drop-in
fetch()over the WebSocket channel; SSE responses arrive as a streamingResponse.body. - libcurl engine: requests are executed by libcurl through Rust bindings.
API Endpoints Summary
Section titled “API Endpoints Summary”All endpoints are relative to your cURL service URL:
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.comRequest execution:
GET /api/v1/curl/request- Simple HTTP requests via GETPOST /api/v1/curl/request- Advanced requests with full options
Sessions:
GET /api/v1/curl/sessions- List all cookie sessionsGET /api/v1/curl/sessions/{id}- Get session detailsGET /api/v1/curl/sessions/{id}/cookies- Get session cookiesDELETE /api/v1/curl/sessions/{id}- Delete session
Jobs:
GET /api/v1/curl/jobs- List async jobsGET /api/v1/curl/jobs/{id}- Get job detailsGET /api/v1/curl/jobs/{id}/result- Get job responseDELETE /api/v1/curl/jobs/{id}- Cancel job
Scheduling:
POST /api/v1/curl/schedule- Create scheduled requestGET /api/v1/curl/schedule- List schedulesGET /api/v1/curl/schedule/{id}- Get schedule detailsPATCH /api/v1/curl/schedule/{id}/toggle- Enable/disable scheduleDELETE /api/v1/curl/schedule/{id}- Remove schedule
Storage:
GET /api/v1/curl/storage- List saved filesGET /api/v1/curl/storage/{path}- Download saved fileDELETE /api/v1/curl/storage/{path}- Delete saved file
Realtime (WebSocket and SSE):
GET /api/v1/curl/channel- Multiplexed request channel (one WebSocket, many concurrent cURLs)GET /api/v1/curl/ws- WebSocket job lifecycle events (alias/ws)GET /api/v1/curl/sse- Server-Sent Events job lifecycle stream (alias/sse)
Operations:
GET /api/v1/curl/health- Service health checkGET /metrics- Prometheus metrics
POST→GET wrapping
Section titled “POST→GET wrapping”hoody-curl can wrap any complex POST request into a simple, shareable GET URL.
A POST request written the traditional way cannot be shared as a link:
curl -X POST "https://api.example.com/search" \ -H "Authorization: Bearer token123" \ -H "Content-Type: application/json" \ -d '{"query": "user data", "filters": {...}}'The same request wrapped as a GET URL:
GET https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?\url=https://api.example.com/search&\method=POST&\header=Authorization: Bearer token123&\json={"query":"user data","filters":{...}}
# The POST is now a GET URL you can:# - Share via email or chat# - Embed in documentation# - Bookmark in a browser# - Use in no-code tools# - Schedule with cronPOST operations become first-class URLs, which makes workflows possible that traditional HTTP does not support: anything that can follow a link can now trigger the request.
Encoding the values
Section titled “Encoding the values”The query is parsed by splitting on & and then on the first =, so slashes, colons, braces, quotes and semicolons all pass through and encoding them only makes the link unreadable.
Four things must be encoded inside a value:
| Character | Encode as | What happens if you don’t |
|---|---|---|
& | %26 | The value ends there. The remainder is read as cURL’s own parameters, so the request runs truncated and returns 200 on something you did not ask for. |
+ | %2B | Decodes to a space. |
# | %23 | The browser treats it as a fragment and never sends it. |
% | %25 | An existing %41 in your value decodes to A. |
Control characters need encoding too: a raw carriage return or newline is stripped by the browser before the request is sent, so the link succeeds while the body silently loses bytes.
The & case is the one that bites, because a shell command containing &&, or a target URL carrying its own query string, hits it immediately:
# Wrong: the value ends at the first &, and "git pull" becomes a parameterjson={"command":"cd /app && git pull"}
# Rightjson={"command":"cd%20/app%20%26%26%20git%20pull"}A request body that would be painful to encode this way can be sent
pre-encoded instead: data_base64 (or its alias body_base64) takes the raw
body as Base64 and is decoded before the upstream call. Both standard and
URL-safe alphabets are accepted, with or without padding. The decoded bytes
take precedence over data and json if those are supplied too.
Magic links
Section titled “Magic links”POST→GET wrapping is the base case. Combine hoody-curl with other Hoody services and any workflow, no matter how complex, becomes a single clickable URL. We call these magic links.
One-click deploy (pull code, install dependencies, restart the service):
GET https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/execute&method=POST&json={"command":"cd%20/app%20%26%26%20git%20pull%20%26%26%20npm%20install%20%26%26%20npm%20run%20build","wait":true}AI-powered summary (send a webpage to Hoody AI, get a summary back):
GET https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://ai.hoody.com/api/v1/chat/completions&method=POST&header=Authorization:%20Bearer%20container-1&json={"model":"anthropic/claude-sonnet-4.5","messages":[{"role":"user","content":"Summarize:%20https://example.com/article"}]}More magic link ideas:
| Magic Link | What it does |
|---|---|
| One-click demo | Restore a snapshot → start the app → return the live URL |
| AI code review | Fetch a file from the container → send to Hoody AI → return review |
| Database export | Query SQLite → format as CSV → save to Files → return download link |
| Health check + auto-heal | Check daemon status → if fatal, restart → return status report |
| Scheduled AI digest | Cron-fetch RSS feeds → send to AI for summarization → save report |
| Webhook relay | GitHub push → pull code in container → rebuild → restart daemon |
| Container factory | Create a new container → configure it → return its service URLs |
| One-click backup | Snapshot container + export SQLite + zip project → return download |
| AI translation | Fetch a doc → send to Hoody AI with target language → return translated version |
| Status dashboard | Query multiple daemons + services → combine into a single JSON health report |
The pattern generalizes: any chain of Hoody API calls can be encoded into a single GET URL, then shared in chat, bookmarked, embedded in a no-code tool, or scheduled with cron. The caller needs no client-side code; opening the link runs the workflow.
Turning a link into a shortcut
Section titled “Turning a link into a shortcut”A wrapped request is a long URL. A proxy alias can hold the whole thing — path and query — so the request gets a short hostname you choose and running it becomes a matter of opening that name.
Point the alias at the container’s curl service and put the entire wrapped request in target_path:
curl -X POST "https://api.hoody.com/api/v1/proxy/aliases" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "container_id": "890abcdef12345678901cdef", "alias": "health", "program": "curl", "index": 1, "target_path": "/api/v1/curl/request?url=https://api.example.com/health&method=GET&response=transparent", "allow_path_override": false }'await client.api.proxy.aliases.create({ container_id: CONTAINER_ID, alias: 'health', program: 'curl', index: 1, target_path: '/api/v1/curl/request?url=https://api.example.com/health&method=GET&response=transparent', allow_path_override: false,});One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Creating the shortcut is itself a wrappable call. Note the & inside
target_path arrives escaped in this link — that is the encoding rule
above doing its job, and it is also why a link like this one cannot in turn
become an alias.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/proxy/aliases&method=POST&bearer_token=TOKEN&json={"container_id":"CONTAINER_ID","alias":"health","program":"curl","index":1,"target_path":"/api/v1/curl/request?url=https://api.example.com/health%26method=GET%26response=transparent","allow_path_override":false}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Opening https://health.{server}.containers.hoody.com/ now performs the wrapped request — the target_path is the hostname’s root landing page. With allow_path_override: false that is the only thing the alias does: it serves only the root, or target_path itself; any other path on the hostname answers 404 ALIAS_PATH_PINNED, and the parameters written in target_path (here url, method and response) keep their values, so a visitor cannot point the request at another URL or change its method by adding ?url= or ?method=. Parameters that target_path does not set are still passed on from the visitor, so write into target_path every parameter you want fixed. The flag limits where the link goes, not who may use it: whoever holds the link is gated by the container’s proxy permissions like any other caller.
Aliases can also expire. Pass expires_at to hand someone a shortcut that stops working on its own.
GET wrapping for AI
Section titled “GET wrapping for AI”GET wrapping is what makes hoody-curl useful to AI tools. Any AI that can fetch a URL, whether ChatGPT, Claude, Claude Code, Cline, or any other agent, can trigger a full Hoody workflow through a single GET request. There is no SDK, no auth ceremony, and no POST body to construct. You wrap the complex operation once; from that point on, any platform capable of fetching a URL can deploy your code, run a database migration, or restart a service.
# Chatbot triggers a full deployment pipeline:GET https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/execute&method=POST&json={"command":"cd%20/app%20%26%26%20git%20pull%20%26%26%20bun%20run%20deploy","wait":true}Combine with @hoody.com Skill delivery: an external AI learns your infrastructure’s API, wraps the critical actions as GET URLs, and hands them back to non-technical users as one-click links. The user sees only the link, not the API call it encodes.
Simple vs advanced requests
Section titled “Simple vs advanced requests”Both forms call the same /api/v1/curl/request endpoint: over HTTP the GET form takes query parameters for simple requests, and the POST form takes a JSON body with the full option set. The CLI and SDK always send the POST form, so run covers simple and advanced requests alike.
# Simple request (GET is the default method)hoody curl run \ --url "https://api.example.com/data" \ --follow-redirects --response json
# Advanced POST requesthoody curl run \ --url "https://api.example.com/users" \ --method POST \ --timeout 30
# Create a scheduled request (6-field cron: second minute hour day month weekday)hoody curl schedules create \ --cron "0 0 9 * * MON-FRI" \ --request-url "https://api.example.com/daily-report" \ --request-method POSTimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Simple request (GET is the default method)const data = await containerClient.curl.run({ url: 'https://api.example.com/data', response: 'json', follow_redirects: true,});
// Advanced POST requestconst result = await containerClient.curl.run({ url: 'https://api.example.com/users', method: 'POST', timeout: 30,});
// Create scheduled request (6-field cron: second minute hour day month weekday)const schedule = await containerClient.curl.schedules.create({ cron: '0 0 9 * * MON-FRI', request: { url: 'https://api.example.com/daily-report', method: 'POST' },});# Simple GET requestcurl "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.example.com/data&follow_redirects=true&response=json"
# Advanced POST requestcurl -X POST "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/users", "method": "POST", "timeout": 30 }'Request limits
Section titled “Request limits”Several option fields carry a ceiling that the service checks before it opens a connection. The OpenAPI schema does not state them, so a value the schema accepts can still come back as 400 Bad Request with the limit named in the error message.
| Field | Accepted range | Rejected |
|---|---|---|
retry_count | 0 to 5 | Above 5 |
retry_delay | 0 to 60 seconds, and only with retry_count > 0 | Above 60, or set at all when retry_count is 0 |
max_redirects | 0 to 50 | Above 50 |
timeout | 1 to 7200 seconds (2 hours) | 0, or above 7200 |
connect_timeout | 1 to 7200 seconds | 0, or above 7200 |
max_filesize | 1 byte to 16 GiB | 0, or above 16 GiB |
Zero is rejected rather than treated as unlimited. In libcurl, timeout: 0 and connect_timeout: 0 mean “no timeout” and max_filesize: 0 means “no size cap”, which would let one request hold a worker thread or a memory budget indefinitely; the service refuses the value instead of silently reinterpreting it. Omit the field to get the service’s own default.
The retry policy is also checked as a whole. Total backoff is the triangular sum, retry_delay x retry_count x (retry_count + 1) / 2, and must stay within a process-wide ceiling of 300 seconds by default, which the operator sets with --max-retry-sleep-secs. So retry_count: 5 with retry_delay: 60 is 900 seconds of sleep and is refused even though each field is individually in range.
Cookie sessions
Section titled “Cookie sessions”Persist authentication across multiple requests:
Log in once, reuse the cookies on later calls, then clear them:
# Log in; cookies are saved under the session automatically.# A JSON object body goes in a file: --json @file.jsonecho '{"user": "john", "pass": "secret"}' > login.jsonhoody curl run \ --url "https://example.com/login" \ --method POST \ --json @login.json \ --session-id user-123
# Saved cookies are sent automaticallyhoody curl run \ --url "https://example.com/api/data" \ --session-id user-123
# Log out and clear the sessionhoody curl sessions delete user-123import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Log in; cookies are saved under the session automaticallyawait containerClient.curl.run({ url: 'https://example.com/login', method: 'POST', json: { user: 'john', pass: 'secret' }, session_id: 'user-123',});
// Saved cookies are sent automaticallyconst data = await containerClient.curl.run({ url: 'https://example.com/api/data', session_id: 'user-123',});
// Log out and clear the sessionawait containerClient.curl.sessions.delete('user-123');# Log in; cookies are saved under the session automaticallycurl -X POST "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/login", "method": "POST", "json": {"user": "john", "pass": "secret"}, "session_id": "user-123" }'
# Saved cookies are sent automaticallycurl -X POST "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/api/data", "session_id": "user-123" }'
# Log out and clear the sessioncurl -X DELETE "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/sessions/user-123"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Each link routes through a different running container’s curl-1
(OTHER_CONTAINER_ID below). The first two forward a body carrying
session_id to CONTAINER_ID’s own curl-1, so the login link’s cookies
carry over to the data call under that session there; the last link
deletes that session directly.
# Log in
https://PROJECT_ID-OTHER_CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request&method=POST&json={"url":"https://example.com/login","method":"POST","json":{"user":"john","pass":"secret"},"session_id":"user-123"}&response=transparent
# Use saved cookies
https://PROJECT_ID-OTHER_CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request&method=POST&json={"url":"https://example.com/api/data","session_id":"user-123"}&response=transparent
# Log out
https://PROJECT_ID-OTHER_CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/sessions/user-123&method=DELETE&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Async jobs
Section titled “Async jobs”Queue long-running requests:
Submit the job, poll it, then collect the result:
# Submit; the response is saved under downloads/by-job/{job_id}/hoody curl run \ --url "https://example.com/large-file.zip" \ --mode async \ --save \ --save-path large-file.zip
# Check status; repeat until status is completed (pending and running are still in progress)hoody curl jobs get JOB_ID
# Collect the result once the job is completedhoody curl jobs result get JOB_IDimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Submit; the response is saved under downloads/by-job/{job_id}/const submitted = await containerClient.curl.run({ url: 'https://example.com/large-file.zip', mode: 'async', save: true, save_path: 'large-file.zip',});
// Poll the status until the job leaves pending and runningconst jobId = submitted.data.job_id;if (!jobId) throw new Error('Async submission did not return a job_id');let job = await containerClient.curl.jobs.get(jobId);while (job.data.status === 'pending' || job.data.status === 'running') { await new Promise((resolve) => setTimeout(resolve, 2000)); job = await containerClient.curl.jobs.get(jobId);}if (job.data.status !== 'completed') throw new Error(`Job ${job.data.status}: ${job.data.error}`);
// Collect the result once the job is completedconst body = await containerClient.curl.jobs.getResult(jobId);# Submit; the response is saved under downloads/by-job/{job_id}/curl -X POST "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/large-file.zip", "mode": "async", "save": true, "save_path": "large-file.zip" }'
# Check status; repeat until status is completed (pending and running are still in progress)curl "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/jobs/JOB_ID"
# Collect the result once the job is completedcurl "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/jobs/JOB_ID/result"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Each link routes through a different running container’s curl-1
(OTHER_CONTAINER_ID below) to CONTAINER_ID’s own curl-1: submits the
download as an async job, then polls status and fetches the result once
it completes, both using the JOB_ID the submit call returns.
# Submit
https://PROJECT_ID-OTHER_CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request&method=POST&json={"url":"https://example.com/large-file.zip","mode":"async","save":true,"save_path":"large-file.zip"}&response=transparent
# Check status
https://PROJECT_ID-OTHER_CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/jobs/JOB_ID&method=GET&response=transparent
# Collect result
https://PROJECT_ID-OTHER_CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/jobs/JOB_ID/result&method=GET&response=transparent Submitting returns 202 Accepted with {"success": true, "job_id": "<uuid>", "message": "Job submitted for execution"}, and the file lands under downloads/by-job/{job_id}/large-file.zip.
WebSocket multiplexed request channel
Section titled “WebSocket multiplexed request channel”Every HTTP call over the public Internet pays for TCP, TLS, and HTTP negotiation: N requests cost N round trips of setup before a byte of real data crosses the wire. /api/v1/curl/channel pays that cost once. It is a single persistent WebSocket over which you multiplex concurrent cURL requests, 64 at a time by default and 128 at the hard cap, with the rest queued up to max_queue. Each has its own stream_id, each is cancellable, and each returns headers, body, and timing exactly like the REST endpoint, without the per-request TCP/TLS setup.
# Open the channel. The server sends a `hello` frame with limits + features.websocat "wss://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/channel"Wire protocol (JSON text frames):
// server → client (on connect){"type":"hello","version":2,"connection_id":"...","limits":{"max_concurrent_streams":...,"max_queue":...,"max_frame_bytes":...,"max_request_bytes":...,"chunk_bytes":...,"stream_timeout_secs":...,"idle_timeout_secs":...,"max_outbound_messages":...},"features":{"buffered":true,"streaming":false,"cache":false,"outbound_pooling":false,"sse":true,"binary_frames":false}}
// client → server (issue a request){"type":"request.start","stream_id":7,"request":{ "url":"https://api.example.com/things", "method":"POST", "json":{"hello":"world"}}}
// server → client (response delivered in chunks). `headers` is the// lowercase-keyed map; `raw_headers` preserves original header order and case.{"type":"accepted","stream_id":7}{"type":"response.start","stream_id":7,"status_code":200,"headers":{...},"raw_headers":[...],"content_type":"...","effective_url":"...","body_bytes":...}{"type":"response.body","stream_id":7,"offset":0,"encoding":"base64","data":"..."}{"type":"response.end","stream_id":7,"timing":{...},"metadata":{...}}
// client → server (cancel mid-flight){"type":"request.cancel","stream_id":7}Response bodies arrive base64-encoded in response.body frames by default, and the channel is buffered: hoody-curl fully buffers each upstream response before emitting its body chunks. Opening the channel with ?binary=true negotiates the binary fast path instead, where response body chunks are raw binary frames and request bodies can be uploaded as binary frames; the server confirms it in features.binary_frames. The OpenAPI contract does not declare that parameter, so the generated TypeScript channel client stays on the JSON text protocol.
Connection pool. The relay keeps one process-wide pool of open
connections, shared by the sync /curl endpoint, the channel WebSocket
path and the async job worker. A pooled connection keeps its TCP
keep-alive, TLS session and DNS result, so a second call to the same host
skips the full handshake. When an AI agent makes many calls to the same
API host, each call after the first saves a full TLS round trip.
A connection is reused only for a request to the same scheme, host and
port, reaching the same IP address the previous transfer actually
connected to, so a DNS change cannot send a request down a connection
bound to a stale address. Requests whose connection settings differ never
share a connection: insecure, proxy and its credentials, and
cert/key/cacert/cert_type each separate them, so a request with
insecure=true never reuses a connection opened by a plain request.
Otherwise, connections may be reused for repeated requests.
Per-request data (Authorization headers, cookies, bearer tokens,
session_id, range, and similar) does not decide reuse, and all of it
is cleared before a pooled connection serves the next request, so one
request’s credentials never carry over to another. hoody-curl does not
authenticate callers (the proxy does that; request credentials are
forwarded to the upstream API). Authenticated API calls, where every
request carries an Authorization: Bearer … header, therefore still get
warm TLS reuse.
Metrics on /metrics:
hoody_curl_pool_takes_total,hoody_curl_pool_hits_total,hoody_curl_pool_misses_total,hoody_curl_pool_puts_totalhoody_curl_pool_evictions_total{reason=per_origin|global|shutdown}hoody_curl_pool_bypasses_total{reason=non_default_security|cancellation|promoted}hoody_curl_pool_idle(gauge)
User-scoped HTTP response cache. Caching is opt-in and needs all three
flags: when the service is started with --cache-enabled (off by default),
--cache-namespace ctn:<project>:<container> and --cache-mode readwrite, the relay serves GET/HEAD responses from a content-addressed
on-disk cache (cacache crate) rooted at <storage>/cache/ns-<sha256(namespace)>/.
The cache is conservative by design:
- One namespace per process, set at startup and never derived from request headers, so there is no spoofing surface.
- The cache only stores responses that are safe to replay independent of
caller identity. Requests carrying
Authorization,Cookie, a bearer token,session_id,range, or any other per-call credential or state are not cached: the cache key does not vary on those fields, so cachingBearer X’s response and serving it toBearer Ywould leak data. The pool still reuses connections for those requests; the two gates are separate. Varyresponses are skipped. Vary support is deferred; the current model assumes the upstream returns a single representation per URL.- Streaming and SSE responses are never cached.
Content-Encoding/Transfer-Encodingare stripped from the stored representation;Content-Lengthis recomputed against the decoded body length. Two size caps catch gzip bombs (compressed Content-Length ≤cache_max_object_bytes, decoded ≤cache_max_object_bytes_decoded).http-cache-semantics = 3.0powers RFC 9111 freshness (Age, Date, Expires, and private-cache semantics;s-maxageis ignored).- DNS-rebind defense: each entry records the IP address the response actually came from. On lookup that address must still be among the host’s current DNS answers, otherwise the entry is bypassed; cached responses from a domain that has moved to a new IP are not served.
- Schema and generation versioning: bump
--cache-generationto invalidate all entries on the next read.
Modes:
--cache-mode off(default): no reads, no writes.--cache-mode readonly: lookups continue, new writes are disabled. Useful for a graceful drain before a deploy.--cache-mode readwrite: full operation.
A missing --cache-enabled or an empty --cache-namespace leaves the cache
module unbuilt whatever the mode says, and nothing on the request path
reports it: watch hoody_curl_cache_enabled to confirm the cache is live.
File modes are 0700 on the root and 0600 on each content file.
Metrics on /metrics:
hoody_curl_cache_enabled(gauge)hoody_curl_cache_hit_total,hoody_curl_cache_miss_totalhoody_curl_cache_object_bytes_stored,hoody_curl_cache_object_count_storedhoody_curl_cache_pin_mismatch_total- DNS-rebind defense activationshoody_curl_cache_skip_request_total{reason=…}- every named skip reason (non_default_security,unsafe_request_header,url_userinfo, …)hoody_curl_cache_skip_response_total{reason=…}- same on the response side (response_set_cookie,vary_present,event_stream,too_large_decoded,pin_mismatch, …)hoody_curl_cache_bypass_total{reason=…}- bypass counters (e.g.cache_disabled,pin_mismatch)
Per-connection tunables (query string):
| Param | Default | Hard cap | Purpose |
|---|---|---|---|
max_concurrent_streams | 64 | 128 | In-flight cURL transfers on this socket |
max_queue | 128 | 4096 | Streams waiting for an execution slot |
max_frame_bytes | 1 MiB | --max-request-body-bytes (default 16 MiB) | Maximum inbound WebSocket frame |
max_request_bytes | --max-request-body-bytes | (same) | Maximum assembled request.start.request JSON size |
chunk_bytes | 64 KiB | 1 MiB | Bytes per outbound response-body chunk |
stream_timeout_secs | 300 | 3600 | Per-stream execution timeout in seconds |
idle_timeout_secs | 60 | 3600 | Idle-connection timeout |
max_outbound_messages | 1024 | 8192 | Outbound queue backpressure threshold |
Every request still flows through the same SSRF guard, header validation, and field-rejection rules as the REST /curl endpoint. The channel is a transport optimization, not a security escape hatch.
Server-sent events (SSE)
Section titled “Server-sent events (SSE)”The web is moving from request/response to long-lived event streams: OpenAI, Anthropic, and most AI agents deliver their output over SSE. A relay that cannot speak SSE holds those calls back, so hoody-curl supports it end to end.
hoody-curl detects when an upstream responds with Content-Type: text/event-stream on a 2xx status and promotes the connection to streaming mode in flight, without a second HTTP call, a body replay, or waiting for the upstream to close. The first SSE frame the upstream emits reaches your client within milliseconds. Non-2xx responses (4xx/5xx) and non-SSE content types fall through to the standard buffered path.
SSE support spans three surfaces:
- Sync
/curland the channel WS auto-promote upstream SSE responses end to end. /sseis a server-emitted SSE stream for the job event bus. It does not proxy an upstream; it streams hoody-curl’s ownjobstarted/jobprogress/jobcompletedevents to EventSource clients.
SSE passthrough on sync /curl
Section titled “SSE passthrough on sync /curl”Just curl the URL. The response is a chunked text/event-stream body, identical to what you’d get from the upstream directly:
curl -N "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://stream.wikimedia.org/v2/stream/recentchange"
# event: message# data: {"$schema":"/mediawiki/recentchange/1.0.0",...}## event: message# data: {...}POST + SSE upstreams (the AI streaming case) work identically:
curl -N -X POST "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.openai.com/v1/chat/completions", "method": "POST", "headers": {"Authorization": "Bearer sk-...", "Content-Type": "application/json"}, "json": {"model": "gpt-4o", "messages": [...], "stream": true} }'The upstream POST body is sent exactly once per attempt: promotion itself does not replay it, and SSE detection fires on the first response byte, so a buffered-mode prelude that gets promoted into streaming still sends the body once. One caveat: if you set retry_count > 0 and the underlying transfer fails before any byte arrives, retries do re-send the body, as with any HTTP client. For non-idempotent POSTs, keep retry_count: 0 and handle retries at the application layer.
One framing-level note: while the upstream is alive, the sync /curl SSE body is byte-identical to what the upstream sent. When the stream closes cleanly, the server appends one synthetic SSE frame, event: end\ndata: {"total_bytes":N}\n\n, so clients learn the total byte count without parsing every event. On a deadline or error, it appends event: error\ndata: {"error_type":"sse_max_duration",...}\n\n instead. SSE clients that subscribe to event: message only (the default) are unaffected; clients that listen on all events should know these tail frames exist.
Typed SSE events on the channel
Section titled “Typed SSE events on the channel”When the channel detects SSE on a stream, you get typed response.sse_event frames instead of response.body frames: one per upstream event, with event, id, data, retry, and a per-stream seq number. The response.start frame carries is_sse: true so the client knows what to expect.
// server → client (SSE-promoted stream){"type":"response.start","stream_id":7,"is_sse":true,"status_code":200,...}{"type":"response.sse_event","stream_id":7,"seq":0,"event":"message","data":"hello"}{"type":"response.sse_event","stream_id":7,"seq":1,"event":"ping","data":"world","id":"42"}// data_truncated: true is set when the upstream event exceeds// --sse-parser-aggregate-bytes (default 1 MiB). `data` is truncated at a// UTF-8 char boundary; the rest of the stream continues normally.{"type":"response.sse_event","stream_id":7,"seq":2,"event":"message","data":"...","data_truncated":true}{"type":"response.end","stream_id":7,"sse_events":3,...}You can run dozens of concurrent SSE streams on one WebSocket. Each is cancellable via request.cancel, bounded by sse_max_duration_secs (default 30 min), and held against a per-process max_sse_concurrent semaphore so a single client can’t exhaust the host.
Job event streams on /ws and /sse
Section titled “Job event streams on /ws and /sse”The same job lifecycle (jobstarted / jobprogress / jobcompleted) is available over both WebSocket and server-sent events. Pick the one your client speaks:
# WebSocket (JSON text frames, full-duplex)websocat "wss://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/ws"# {"type":"jobstarted","job_id":"...","name":"..."}# {"type":"jobprogress","job_id":"...","progress":0.42}# {"type":"jobcompleted","job_id":"...","status":"completed"}
# Server-Sent Events (text, EventSource-friendly, browser-native)curl -N "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/sse"# retry: 5000## event: jobstarted# data: {"job_id":"...","name":"..."}# id: 0## event: jobprogress# data: {"job_id":"...","progress":0.42}# id: 1Both filter by ?job_id=<uuid>. Heartbeats keep reverse proxies happy: /sse emits a :\n\n comment every --sse-heartbeat-secs (default 15s) of silence; /ws sends WebSocket pings every 30s and drops the connection if no Pong arrives within 90s (half-open detection).
SSE configuration flags
Section titled “SSE configuration flags”| Flag | Default | Purpose |
|---|---|---|
--no-sse | enabled | Disable the /sse route and upstream SSE auto-detection (compatibility escape hatch) |
--sse-heartbeat-secs | 15 | Idle heartbeat interval |
--sse-max-duration-secs | 1800 | Wall-clock cap on any single SSE stream |
--sse-channel-capacity | 256 | Bounded mpsc between executor and handler |
--sse-parser-aggregate-bytes | 1 MiB | Hard cap on one event’s accumulated bytes |
--sse-parser-partial-bytes | 256 KiB | Hard cap on a single partial line |
--max-sse-concurrent | 256 | Global cap on concurrent SSE streams |
When the global SSE cap is exhausted, sync /curl and /sse return 503 Retry-After: 5, and channel WS ends the stream with error{error_type:"sse_capacity"} and nothing else: no response.start, no response.end, never cancelled. The error is delivered even while the client is slow to read the connection.
Channel error_type vocabulary
Section titled “Channel error_type vocabulary”Channel WS surfaces errors as {"type":"error","error_type":"<kind>","message":"...","stream_id":N}. The error_type is a stable enum your SDK should switch on:
error_type | Meaning | Retry strategy |
|---|---|---|
validation_error | Invalid request (bad URL, rejected field, etc.) | Don’t retry; fix the request. |
cancelled | Stream cancelled by client or server | N/A (caller initiated). |
timeout | stream_timeout_secs elapsed before completion | Retry with longer timeout, or use SSE for long-lived streams. |
queue_full | Per-connection max_queue exhausted | Back off + retry, or raise max_queue in query string. |
sse_capacity | Global max_sse_concurrent exhausted | Back off + retry (mirrors 503 + Retry-After). |
sse_max_duration | SSE stream exceeded sse_max_duration_secs | Reopen; consider chunking the upstream call. |
execution_error | libcurl-level error (DNS, TLS, upstream RST) | Retry once with backoff; permanent if it repeats. |
internal_error | SDK or server bug | Don’t retry blindly; surface to operator. |
protocol_error | Channel wire violation (duplicate stream_id, etc.) | Bug in client. |
The Last-Event-Id header on /sse is accepted but ignored: the broadcast bus has no replay buffer, so reconnecting clients miss events emitted during the gap. Operators who want durable replay should fan the bus out to an external durable queue.
TypeScript SDK
Section titled “TypeScript SDK”The hoody-sdk package includes a channel client that runs your existing fetch-based code over the channel with one line of setup:
import { createCurlFetch } from "hoody-sdk";
const fetch = createCurlFetch({ url: "wss://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/channel",});
// Now use it exactly like global fetch.const res = await fetch("https://api.example.com/things", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ hello: "world" }),});console.log(res.status, await res.json());SSE upstreams transparently return a streaming Response.body; pipe it to EventSource-style code without changes:
const messages = [{ role: "user", content: "hello" }];const res = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` }, body: JSON.stringify({ model: "gpt-4o", messages, stream: true }),});if (!res.body) throw new Error("no body");const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();while (true) { const { done, value } = await reader.read(); if (done) break; process.stdout.write(value);}For parsed events instead of raw bytes, drop to the low-level API:
import { CurlChannel } from "hoody-sdk";
const messages = [{ role: "user", content: "hello" }];const channel = await CurlChannel.open({ url: "wss://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/channel" });const stream = channel.request({ url: "https://api.openai.com/v1/chat/completions", method: "POST", json: { model: "gpt-4o", messages, stream: true },});
const start = await stream.start;if (start.is_sse) { for await (const ev of stream.events) { console.log(ev.event, ev.data); // typed { event, id?, data, retry?, seq } }}Standard AbortController cancels mid-flight via request.cancel on the wire; the SDK rejects stream.start / fetch() immediately on signal.abort() without waiting for the server’s cancelled ack. The client runs in browsers and in Node. On a client already scoped with withContainer(), box.curl.channel.connect() opens the same channel without writing the URL by hand, and returns the CurlChannel.
Auto-reconnect
Section titled “Auto-reconnect”The channel reconnects automatically on transport drop with exponential backoff. A request issued mid-reconnect waits transparently for the next live socket, so the caller does not need a retry loop.
const channel = await CurlChannel.open({ url: "wss://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/channel", reconnect: { enabled: true, // default initialBackoffMs: 500, // default maxBackoffMs: 30_000, // default jitter: 0.2, // ±20% randomization on each delay maxAttempts: Infinity, // default; set a finite cap if you want to fail fast },});To opt out, pass reconnect: { enabled: false }; the channel then rejects in-flight streams and stays closed on the first drop.
Observability hooks
Section titled “Observability hooks”Every channel state transition fires a typed hook. Throwing inside a hook never wedges the state machine: exceptions are caught and logged with console.warn, so callers can use hooks safely for logging, metrics, or auth refresh:
const channel = await CurlChannel.open({ url: "wss://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/channel", hooks: { onOpen: (hello) => log.info("channel open", hello.connection_id), onClose: ({ code, reason, willReconnect }) => metrics.inc("ws.close"), onReconnecting: ({ attempt, backoffMs }) => log.warn(`reconnect #${attempt} in ${backoffMs}ms`), onRequestStart: ({ streamId, url, method }) => metrics.inc("req.start"), onResponseStart: ({ streamId, status, isSse }) => metrics.observe("status", status), onResponseEnd: ({ streamId, totalBytes }) => metrics.observe("bytes", totalBytes), onError: (err) => log.error("channel error", err), },});Error handling
Section titled “Error handling”Abort errors are real DOMException("...", "AbortError") instances when the runtime supports them (browser, Node ≥17.3), with a fallback to an AbortError-named class otherwise. Either way, err.name === "AbortError" works everywhere. Channel-level failures throw CurlChannelError carrying an errorType from the same vocabulary as the wire protocol (validation_error, cancelled, sse_capacity, …).
Sharp edges to know:
- SSE event queue is bounded. A slow consumer combined with a fast upstream is capped at 4 096 buffered events; when full, the SDK emits a synthetic
{ event: "dropped", … }event, sendsrequest.cancelupstream, and the iterator terminates. Drain the iterator promptly or accept the dropped marker as a signal of lost data. - Handle
sse_capacityon the channel. When the global--max-sse-concurrentsemaphore is exhausted, channel SSE streams receive{"type":"error","error_type":"sse_capacity",...}as their only terminal frame (noresponse.start, noresponse.end); the sync/curland/ssepaths instead return503 Retry-After: 5. Map both to a client-side retry with backoff.
Scheduled requests
Section titled “Scheduled requests”Run recurring requests on a cron schedule:
A daily report at 9 AM on weekdays, and an hourly health check that retries:
# Daily report, 9 AM on weekdays (6-field cron: second minute hour day month weekday)hoody curl schedules create \ --cron "0 0 9 * * MON-FRI" \ --request-url "https://api.example.com/daily-report" \ --request-method POST \ --request-json '{"send_to": "ops@example.com"}'
# Hourly health check with retrieshoody curl schedules create \ --cron "0 0 * * * *" \ --request-url "https://api.example.com/health" \ --request-retry-count 3import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Daily report, 9 AM on weekdays (6-field cron: second minute hour day month weekday)await containerClient.curl.schedules.create({ cron: '0 0 9 * * MON-FRI', request: { url: 'https://api.example.com/daily-report', method: 'POST', json: { send_to: 'ops@example.com' }, },});
// Hourly health check with retriesawait containerClient.curl.schedules.create({ cron: '0 0 * * * *', request: { url: 'https://api.example.com/health', retry_count: 3, },});# Daily report, 9 AM on weekdays (6-field cron: second minute hour day month weekday)curl -X POST "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/schedule" \ -H "Content-Type: application/json" \ -d '{ "cron": "0 0 9 * * MON-FRI", "request": { "url": "https://api.example.com/daily-report", "method": "POST", "json": {"send_to": "ops@example.com"} } }'
# Hourly health check with retriescurl -X POST "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/schedule" \ -H "Content-Type: application/json" \ -d '{ "cron": "0 0 * * * *", "request": { "url": "https://api.example.com/health", "retry_count": 3 } }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Each link routes through a different running container’s curl-1
(OTHER_CONTAINER_ID below) and creates the recurring schedule on
CONTAINER_ID’s own curl-1, which then runs the nested request on
the given cron expression from then on.
# Daily report
https://PROJECT_ID-OTHER_CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/schedule&method=POST&json={"cron":"0%200%209%20*%20*%20MON-FRI","request":{"url":"https://api.example.com/daily-report","method":"POST","json":{"send_to":"ops@example.com"}}}&response=transparent
# Hourly health check
https://PROJECT_ID-OTHER_CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/schedule&method=POST&json={"cron":"0%200%20*%20*%20*%20*","request":{"url":"https://api.example.com/health","retry_count":3}}&response=transparent Response modes
Section titled “Response modes”The default depends on how you entered, not on the upstream method: a GET
/api/v1/curl/request with query parameters defaults to JSON mode, while a
request whose parameters arrive in a JSON body defaults to transparent
mode. Pass response=json or response=transparent when you want the same
shape from both entry points.
JSON mode returns a structured response with timing and metadata:
{ "success": true, "status_code": 200, "is_binary": false, "headers": {"content-type": "application/json"}, "body": "{\"message\": \"Hello\"}", "job_id": null, "timing": {"namelookup": 0.012, "connect": 0.043, "pretransfer": 0.045, "redirect": 0.0, "starttransfer": 0.128, "total": 0.135}, "metadata": {"effective_url": "https://api.example.com", "content_type": "application/json", "redirect_count": 0, "size_download": 31, "size_upload": 0, "speed_download": 229.63, "speed_upload": 0}}Transparent mode returns the raw response body:
curl "https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.example.com&response=transparent"# Returns: Raw API response (JSON, HTML, etc.)Use Cases
Section titled “Use Cases”API testing & debugging
Section titled “API testing & debugging”Execute API calls through Hoody’s network, test endpoints with different parameters, and share request URLs with team members.
Web scraping
Section titled “Web scraping”Schedule recurring scrapes with cron, persist cookies for authenticated scraping, save responses directly to storage, and retry transient failures automatically.
Webhook receivers
Section titled “Webhook receivers”Transform webhooks into GET URLs, share webhook endpoints, schedule webhook calls for testing, and persist webhook history in storage.
API aggregation
Section titled “API aggregation”Chain multiple API calls via sessions, orchestrate multi-step workflows, persist state across distributed requests, and add retry logic for reliability.
No-code integration
Section titled “No-code integration”Turn complex API calls into simple URLs, embed them in no-code tools that only support GET, share API access without exposing credentials, and make any API bookmarkable.
Monitoring & alerts
Section titled “Monitoring & alerts”Schedule health checks for external services, retry failed requests automatically, save responses for historical analysis, and trigger notifications on status changes.
Best Practices
Section titled “Best Practices”Session management
Section titled “Session management”Use descriptive session IDs (user-123-session) and keep one session per user or context for isolation. Sessions persist indefinitely until deleted, so delete them when done to free memory.
Async vs sync
Section titled “Async vs sync”Use mode: "sync" for quick requests (under 30 seconds) and mode: "async" for downloads or slow APIs. Poll job status before retrieving results, and clean up completed jobs periodically.
Scheduling
Section titled “Scheduling”Test the cron expression before scheduling. Set retry_count for reliability, pause a schedule during debugging with PATCH /api/v1/curl/schedule/{id}/toggle and body {"enabled": false}, and monitor schedule execution via the jobs API.
Response storage
Section titled “Response storage”Set save: true to persist responses under downloads/by-job/{job_id}/, and organize files with nested paths relative to that job directory (reports/2024/monthly.csv). Clean up old files to manage disk space. Storage suits audit trails and caching; access it via the storage API or the Files service.
Errors and retries
Section titled “Errors and retries”Configure retry_count for transient failures, set timeout and connect_timeout within the ranges in Request limits, check status_code in responses, and use sessions for authentication retries.
Useful Questions
Section titled “Useful Questions”Q: How do I share a complex POST request? Use Hoody cURL to wrap it: the POST becomes a GET URL with all parameters encoded. Share this URL freely.
Q: Can I schedule recurring API calls? Yes. Use the schedule endpoint with cron expressions for daily reports, hourly syncs, or periodic health checks.
Q: How do I maintain authentication across requests?
Use sessions: provide a session_id, and cookies are saved and included in subsequent requests automatically.
Q: What’s the difference between sync and async mode? Sync waits for the response; async creates a background job. Use async for slow requests or downloads.
Q: Can I save API responses to files?
Yes. Set save: true and optionally provide save_path, then access saved files via the storage API.
Q: How do I retry failed requests?
Set retry_count in your request; the service retries automatically on network errors or timeouts. Five is the maximum, and the total backoff has its own ceiling; see Request limits.
Q: Can I use this behind a proxy?
No. The proxy, proxy_user, and proxy_password fields are rejected with a 400 as a deliberate SSRF defense: libcurl connects to the proxy address before URL validation could inspect it.
Troubleshooting
Section titled “Troubleshooting”Request fails with network error
Section titled “Request fails with network error”Cause: The target server is unreachable, or the request timed out.
Solution: Check the timeout and connect_timeout settings, verify the target URL is accessible, and use retry_count for transient failures.
Session cookies not persisting
Section titled “Session cookies not persisting”Cause: The session ID does not match, or the cookies expired.
Solution: Use the exact same session_id for all requests, confirm the session exists with GET /sessions/{id}, check the target site’s cookie expiration, and delete and recreate the session if it is corrupted.
Scheduled request not running
Section titled “Scheduled request not running”Cause: The cron expression is invalid, or the schedule is disabled.
Solution: Test the cron expression before scheduling, check that the schedule is enabled: true, verify the next_run timestamp is in the future, and monitor the jobs API for execution history.
Async job stays pending
Section titled “Async job stays pending”Cause: A queue backlog or a job-system issue.
Solution: Check the queue with GET /jobs, cancel stuck jobs with DELETE /jobs/{id}, monitor the active job count, and restart the service if the queue is stuck.
Response too large
Section titled “Response too large”Cause: The target returns a very large response.
Solution: Use save: true to stream to disk, set a lower max_filesize limit, use range requests if the target supports them, or paginate at the source.
Storage full
Section titled “Storage full”Cause: Too many saved responses.
Solution: List storage with GET /storage, delete old files, add a cleanup schedule, and organize files under date-based paths so old ones are easy to expire.