Skip to content
Discord Get Started

http — HTTP Client

Make HTTP requests directly from SQL. Functions are available in two calling conventions: scalar (returns JSONB) and table (returns rows).

The http extension is pre-enabled — no CREATE EXTENSION required.

The scalar form returns a single JSONB value containing the full response. This is the recommended calling convention for new code.

SQL
-- GET request — returns JSONB
SELECT http_get('https://api.example.com/data');
-- Extract fields from the JSONB response
SELECT (http_get('https://httpbin.org/get'))->>'status' AS status;
-- POST with JSON body
SELECT http_post(
'https://api.example.com/endpoint',
'{"key": "value"}',
'application/json'
);
-- Parse the response content as JSON
SELECT (http_get('https://api.ipify.org?format=json')->>'content')::json->>'ip' AS my_ip;

The JSONB object contains:

KeyTypeDescription
statusnumberHTTP status code (200, 404, etc.).
contentstringResponse body text.
content_typestringResponse Content-Type header.
headersarrayResponse headers as [{"field": "...", "value": "..."}, ...].
FunctionDescription
http_get(url, headers?)HTTP GET, returns JSONB. Optional headers JSONB.
http_post(url, body, content_type, headers?)HTTP POST, returns JSONB. Optional headers JSONB.
http_put(url, body, content_type, headers?)HTTP PUT, returns JSONB. Optional headers JSONB.
http_delete(url, headers?)HTTP DELETE, returns JSONB. Optional headers JSONB.
http_head(url, headers?)HTTP HEAD, returns JSONB. Optional headers JSONB.
http_patch(url, body, content_type, headers?)HTTP PATCH, returns JSONB. Optional headers JSONB.
http(method, url, headers?, content_type?, body?)Generic HTTP request with full control, returns JSONB. Only method and url are required. method must be one of GET, POST, PUT, DELETE, HEAD, PATCH, matched case-insensitively (get and Get both work). Any other verb — and a value with surrounding whitespace — raises HTTP method is unsupported (22023).
SQL
-- Send alert to webhook
SELECT http_post(
'https://hooks.slack.com/services/xxx/yyy/zzz',
'{"text": "Database backup completed!"}',
'application/json'
);

One statement is enough when every endpoint answers. status carries the HTTP status code, so 4xx and 5xx replies come back as ordinary values:

SQL
-- Status codes for endpoints that all respond
SELECT
url,
(http_head(url)->>'status')::int AS status
FROM (VALUES
('https://db9.ai'),
('https://example.com'),
('https://httpbin.org/status/503')
) t(url);

Health Check That Survives Unreachable Endpoints

Section titled “Health Check That Survives Unreachable Endpoints”

Move the request into a PL/pgSQL function with its own EXCEPTION handler. Each URL is then isolated: a raise is turned into a row instead of ending the statement.

SQL
CREATE OR REPLACE FUNCTION http_probe(u TEXT)
RETURNS TABLE (status INT, error TEXT)
LANGUAGE plpgsql AS $$
BEGIN
RETURN QUERY SELECT (http_head(u)->>'status')::int, NULL::text;
EXCEPTION WHEN OTHERS THEN
RETURN QUERY SELECT NULL::int, SQLERRM;
END $$;
SELECT t.url, p.status, p.error
FROM (VALUES
('https://db9.ai'),
('https://nxdomain.invalid'),
('https://httpbin.org/status/503')
) t(url),
LATERAL http_probe(t.url) p;
url | status | error
--------------------------------+--------+--------------------------------------
https://db9.ai | 200 |
https://nxdomain.invalid | | could not resolve HTTP resource host
https://httpbin.org/status/503 | 503 |
(3 rows)

An endpoint that replied puts its code in status — including 503 and every other 4xx/5xx. An endpoint that could not be reached at all puts the reason in error. Both kinds of row survive, so the check reports on all three endpoints rather than stopping at the first one that is down.

Pass request headers as a JSONB value. Two formats are supported:

SQL
-- Object format (recommended)
'{"Authorization": "Bearer sk-..."}'::jsonb
-- Array format (pgsql-http compatible)
'[{"field": "Authorization", "value": "Bearer sk-..."}]'::jsonb
SQL
-- GET with Bearer token
SELECT
(http_get(
'https://api.example.com/protected',
'{"Authorization": "Bearer sk-your-token-here"}'::jsonb
))->>'content' AS body;
-- POST with Bearer token
SELECT
(http_post(
'https://api.example.com/data',
'{"event": "user_signup"}',
'application/json',
'{"Authorization": "Bearer sk-your-token-here"}'::jsonb
))->>'status' AS status;
SQL
-- X-API-Key header
SELECT
(http_post(
'https://api.example.com/webhook',
'{"event": "test"}',
'application/json',
'{"X-API-Key": "my-api-key"}'::jsonb
))->>'status' AS status;
-- Vendor-specific key header (e.g. OpenAI, Anthropic)
SELECT
(http_post(
'https://api.openai.com/v1/chat/completions',
'{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "Hello"}]}',
'application/json',
'{"Authorization": "Bearer sk-proj-...", "OpenAI-Organization": "org-..."}'::jsonb
))->>'content' AS response;
SQL
-- Pass several headers at once (object format)
SELECT http_get(
'https://api.example.com/data',
'{"Authorization": "Bearer token", "X-Request-ID": "req-001", "Accept": "application/json"}'::jsonb
);
-- Using the generic http() function for full control
SELECT http(
'POST',
'https://api.example.com/endpoint',
'{"Authorization": "Bearer token", "X-Idempotency-Key": "key-123"}'::jsonb,
'application/json',
'{"payload": "data"}'
);

To avoid repeating credentials inline, store them in a table and join at query time:

SQL
CREATE TABLE api_credentials (
name TEXT PRIMARY KEY,
headers JSONB NOT NULL
);
INSERT INTO api_credentials VALUES (
'my-api',
'{"Authorization": "Bearer sk-your-token"}'::jsonb
);
-- Use stored headers in a request
SELECT http_get('https://api.example.com/data', headers)
FROM api_credentials
WHERE name = 'my-api';

Security note: Credentials stored in tables are visible to any role with SELECT access. Use row-level security or store secrets outside the database for production workloads.

DB9 splits HTTP outcomes into two channels, and they are handled differently:

  • The server replied — any code, including 4xx and 5xx, arrives in the status field as an ordinary value. Branch on it in SQL.
  • The server never replied — DNS, connect, timeout, redirect limit, response size and non-UTF-8 failures raise a PostgreSQL error and abort the statement. No status is produced, so no amount of status checking catches them. Use EXCEPTION handling, as in Health Check That Survives Unreachable Endpoints.

The status field in the JSONB response contains the HTTP status code. Check it before using the response body:

SQL
-- Check status before using the response
SELECT
CASE
WHEN (response->>'status')::int = 200 THEN response->>'content'
WHEN (response->>'status')::int = 401 THEN 'Unauthorized — check your API key'
WHEN (response->>'status')::int = 429 THEN 'Rate limited — retry later'
ELSE 'Error: HTTP ' || (response->>'status')::int
END AS result
FROM (SELECT http_get('https://api.example.com/data') AS response) r;
SQL
-- Only process rows where the API returned 200
SELECT url, (response->>'content')::jsonb AS data
FROM my_urls
CROSS JOIN LATERAL (SELECT http_get(my_urls.url) AS response) r
WHERE (response->>'status')::int = 200;

The WHERE clause filters replies, so a row that returned 404 or 500 is dropped as intended. It cannot filter out a row whose host is unreachable: that row raises, and the raise takes the whole query with it. When my_urls may contain an unreachable host, narrow the list with http_probe() first and fetch only the URLs that answered:

SQL
SELECT m.url, p.status
FROM my_urls m,
LATERAL http_probe(m.url) p
WHERE p.status = 200;

Use RAISE EXCEPTION inside a function or DO block to abort on non-200:

SQL
DO $$
DECLARE
response JSONB;
status_code INT;
BEGIN
response := http_post(
'https://api.example.com/notify',
'{"event": "test"}',
'application/json',
'{"Authorization": "Bearer sk-..."}'::jsonb
);
status_code := (response->>'status')::int;
IF status_code < 200 OR status_code >= 300 THEN
RAISE EXCEPTION 'HTTP request failed with status %: %',
status_code, response->>'content';
END IF;
END $$;

Handle timeouts and other transport failures

Section titled “Handle timeouts and other transport failures”

A request that exceeds 5 seconds raises a PostgreSQL error, and so does every other failure that produces no reply. EXCEPTION WHEN OTHERS catches the whole class, which is why the same handler works for a timeout and for a host that does not resolve:

SQL
DO $$
BEGIN
PERFORM http_get('https://slow-api.example.com/data');
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'HTTP request failed: %', SQLERRM;
END $$;

SQLERRM reports whichever failure occurred, so read it rather than assuming a timeout — the placeholder host above does not resolve, so it warns HTTP request failed: could not resolve HTTP resource host.

Error messages never include the request URL, host or port, so identify the failing call from the statement itself.

ErrorSQLSTATECause
insecure HTTP requests are disabled42501Plain http:// URL — use https://
only HTTP and HTTPS URL schemes are supported22023The URL names some other scheme, such as ftp://
URL host or address is not allowed by network policy42501SSRF protection blocked the hostname, or the IP address it names or resolves to (loopback, private, link-local)
URL port is not allowed by network policy42501The URL names a port other than 443. The port is checked before the address, so a blocked IP on a blocked port reports this message rather than the network-policy one
URL user information is not allowed42501The URL contains user:pass@ credentials
could not resolve HTTP resource host58030DNS lookup for the host failed
could not connect to HTTP resource58030The connection could not be made within the 1-second connect timeout, or no response arrived within the 5-second request timeout. A slow endpoint and an unreachable one produce the same message
HTTP redirect limit exceeded (max 3)58030The redirect chain was longer than 3 hops
HTTP response stream failed58030The response started but its body was still arriving when the 5-second request timeout expired
HTTP response exceeds the 1048576-byte limit54000Response body exceeded the 1 MB limit
HTTP response is not valid UTF-858030The response body is binary or in a non-UTF-8 encoding. Encoding is validated as the body streams in, so an oversized binary body reports this rather than the size limit
HTTP request body exceeds the 262144-byte limit54000Request body exceeded the 256 KB limit
http request limit exceeded (max 100 per statement)54000The statement tried to issue more than 100 http calls
LimitValue
ProtocolHTTPS only (HTTP blocked)
Max requests per statement100
Max concurrent requests20 in flight per tenant per node (not per statement)
Max response size1 MB (1048576 bytes)
Max request body256 KB (262144 bytes)
Max redirects3
Request timeout5 seconds (1-second connect timeout)
SSRF protectionPrivate/internal IP ranges blocked

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