Skip to content
Discord Get Started

HTTP from SQL

DB9’s http extension lets you make HTTP requests from SQL — fetch data from APIs, post to webhooks, or check service health without leaving the database. This is useful when agents need to call external services as part of a SQL workflow, or when you want to enrich query results with live data.

This guide covers the common patterns, shows working examples, and explains the security and operational limits.

  • A DB9 database (see Quick Start)
  • The http extension is enabled by default — no CREATE EXTENSION needed

The http extension is one of two extensions enabled by default (along with pg_cron). Running CREATE EXTENSION http is harmless but unnecessary. All functions live in the extensions schema.

Fetch data from an external API:

SQL
SELECT status, content
FROM extensions.http_get('https://httpbin.org/get?name=db9');

The function returns a single row with four columns:

ColumnTypeDescription
statusINTHTTP status code (200, 404, 500, etc.)
content_typeTEXTResponse Content-Type header, or NULL
headersJSONBResponse headers as a JSON array
contentTEXTResponse body as text

Most APIs return JSON. Cast content to JSONB to extract fields:

SQL
SELECT
status,
content::jsonb->>'origin' AS origin_ip
FROM extensions.http_get('https://httpbin.org/get');

Send data with http_post():

SQL
SELECT status, content::jsonb->>'json' AS echoed_body
FROM extensions.http_post(
'https://httpbin.org/post',
'{"event": "user_signup", "user_id": 42}',
'application/json'
);

The three required arguments are:

  1. url — the endpoint
  2. body — request body as text
  3. content_type — the Content-Type header value
SQL
SELECT status
FROM extensions.http_post(
'https://httpbin.org/post',
'username=alice&action=login',
'application/x-www-form-urlencoded'
);

Pass headers as JSONB — either object or array format:

SQL
-- Object format (simpler)
SELECT status, content
FROM extensions.http_get(
'https://httpbin.org/headers',
'{"Authorization": "Bearer sk-test-123", "X-Request-ID": "req-abc"}'::jsonb
);
-- Array format (pgsql-http compatible)
SELECT status, content
FROM extensions.http_get(
'https://httpbin.org/headers',
'[{"field": "Authorization", "value": "Bearer sk-test-123"}]'::jsonb
);

All standard methods are available:

SQL
-- PUT
SELECT status FROM extensions.http_put(
'https://httpbin.org/put',
'{"name": "updated"}',
'application/json'
);
-- PATCH
SELECT status FROM extensions.http_patch(
'https://httpbin.org/patch',
'{"status": "active"}',
'application/json'
);
-- DELETE
SELECT status FROM extensions.http_delete('https://httpbin.org/delete');
-- HEAD (returns headers only, no body)
SELECT status, headers FROM extensions.http_head('https://httpbin.org/get');

The http() function accepts the method as a string — useful when the method comes from a column or variable:

SQL
SELECT status, content
FROM extensions.http(
'POST',
'https://httpbin.org/post',
'{"Authorization": "Bearer token"}'::jsonb,
'application/json',
'{"payload": "data"}'
);

Arguments: method, url, [headers], [content_type], [body].

SQL
SELECT status
FROM extensions.http_post(
'https://hooks.slack.com/services/T00/B00/xxx',
'{"text": "New signup: user_id=42"}',
'application/json'
);
SQL
SELECT
u.id,
u.email,
(h.content::jsonb->>'company') AS company
FROM users u
CROSS JOIN LATERAL extensions.http_get(
'https://api.example.com/enrich?email=' || u.email
) h
WHERE u.needs_enrichment = true
LIMIT 10;
SQL
CREATE TABLE api_snapshots (
id SERIAL PRIMARY KEY,
fetched_at TIMESTAMPTZ DEFAULT now(),
status INT,
body JSONB
);
INSERT INTO api_snapshots (status, body)
SELECT status, content::jsonb
FROM extensions.http_get('https://api.example.com/metrics');
SQL
SELECT
url,
(extensions.http_head(url)).status AS status_code
FROM (VALUES
('https://api.example.com/health'),
('https://db9.ai'),
('https://httpbin.org/status/200')
) t(url);

The http extension has built-in SSRF (Server-Side Request Forgery) protection:

  • HTTPS only — plain HTTP requests are blocked by default
  • Private IPs blocked — requests to 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and link-local ranges are rejected
  • DNS rebinding protection — domain names are resolved and the resulting IP is validated against the same rules
  • No credentials in URLsuser:pass@host syntax is rejected
  • Port restricted — only standard ports (443 for HTTPS, 80 for HTTP) are allowed

If a request is blocked, you get a clear error message:

http: insecure http requests are disabled
http: host is not allowed
http: ip is not allowed
LimitValue
Request timeout5 seconds (1 second connect timeout)
Max response body1 MB
Max request body256 KB
Max redirects3
Max requests per SQL statement100
Concurrent requests per tenant20 (5 reserved for interactive, 15 shared)

These limits are fixed and cannot be changed with session parameters.

  • Large responses — if an API returns more than 1 MB, the request fails. Paginate or filter on the API side.
  • Slow APIs — anything over 5 seconds total (including DNS, connect, and transfer) times out.
  • Bulk calls — a single SELECT that calls http_get() for 100+ rows hits the per-statement limit. Break into batches.
  • Concurrent load — 20 concurrent requests per tenant prevents one database from monopolizing network resources.
  • Superuser only — all http functions require the database admin role.
  • UTF-8 responses only — non-UTF-8 response bodies cause an error. Binary APIs (images, protobuf) are not supported.
  • HEAD requests don’t follow redirectshttp_head() returns the redirect status (301, 302) rather than following it. Use http_get() if you need to follow redirects.
  • Redirect method changes — on 301-303 redirects, POST/PUT/PATCH become GET. Only 307-308 preserve the original method.
  • No proxy support — environment proxy variables are ignored.