http — HTTP Client
Make HTTP requests directly from SQL. Functions are available in two calling conventions: scalar (returns JSONB) and table (returns rows).
Installation
Section titled “Installation”The http extension is pre-enabled — no CREATE EXTENSION required.
Scalar Functions (JSONB)
Section titled “Scalar Functions (JSONB)”The scalar form returns a single JSONB value containing the full response. This is the recommended calling convention for new code.
-- GET request — returns JSONBSELECT http_get('https://api.example.com/data');
-- Extract fields from the JSONB responseSELECT (http_get('https://httpbin.org/get'))->>'status' AS status;
-- POST with JSON bodySELECT http_post( 'https://api.example.com/endpoint', '{"key": "value"}', 'application/json');
-- Parse the response content as JSONSELECT (http_get('https://api.ipify.org?format=json')->>'content')::json->>'ip' AS my_ip;Scalar Response Shape
Section titled “Scalar Response Shape”The JSONB object contains:
| Key | Type | Description |
|---|---|---|
status | number | HTTP status code (200, 404, etc.). |
content | string | Response body text. |
content_type | string | Response Content-Type header. |
headers | array | Response headers as [{"field": "...", "value": "..."}, ...]. |
Available Scalar Functions
Section titled “Available Scalar Functions”| Function | Description |
|---|---|
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). |
Table Functions (Row)
Section titled “Table Functions (Row)”Real-World Examples
Section titled “Real-World Examples”POST to Webhook
Section titled “POST to Webhook”-- Send alert to webhookSELECT http_post( 'https://hooks.slack.com/services/xxx/yyy/zzz', '{"text": "Database backup completed!"}', 'application/json');Check URL Status
Section titled “Check URL Status”One statement is enough when every endpoint answers. status carries the HTTP status code, so 4xx and 5xx replies come back as ordinary values:
-- Status codes for endpoints that all respondSELECT url, (http_head(url)->>'status')::int AS statusFROM (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.
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.errorFROM (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.
Custom Headers
Section titled “Custom Headers”Pass request headers as a JSONB value. Two formats are supported:
-- Object format (recommended)'{"Authorization": "Bearer sk-..."}'::jsonb
-- Array format (pgsql-http compatible)'[{"field": "Authorization", "value": "Bearer sk-..."}]'::jsonbBearer Token Authentication
Section titled “Bearer Token Authentication”-- GET with Bearer tokenSELECT (http_get( 'https://api.example.com/protected', '{"Authorization": "Bearer sk-your-token-here"}'::jsonb ))->>'content' AS body;
-- POST with Bearer tokenSELECT (http_post( 'https://api.example.com/data', '{"event": "user_signup"}', 'application/json', '{"Authorization": "Bearer sk-your-token-here"}'::jsonb ))->>'status' AS status;API Key Header
Section titled “API Key Header”-- X-API-Key headerSELECT (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;Multiple Headers
Section titled “Multiple Headers”-- 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 controlSELECT http( 'POST', 'https://api.example.com/endpoint', '{"Authorization": "Bearer token", "X-Idempotency-Key": "key-123"}'::jsonb, 'application/json', '{"payload": "data"}');Store a token in a table for reuse
Section titled “Store a token in a table for reuse”To avoid repeating credentials inline, store them in a table and join at query time:
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 requestSELECT http_get('https://api.example.com/data', headers)FROM api_credentialsWHERE name = 'my-api';Security note: Credentials stored in tables are visible to any role with
SELECTaccess. Use row-level security or store secrets outside the database for production workloads.
Error Handling
Section titled “Error Handling”DB9 splits HTTP outcomes into two channels, and they are handled differently:
- The server replied — any code, including 4xx and 5xx, arrives in the
statusfield 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
statusis produced, so no amount ofstatuschecking catches them. UseEXCEPTIONhandling, as in Health Check That Survives Unreachable Endpoints.
Check HTTP status codes
Section titled “Check HTTP status codes”The status field in the JSONB response contains the HTTP status code. Check it before using the response body:
-- Check status before using the responseSELECT 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 resultFROM (SELECT http_get('https://api.example.com/data') AS response) r;Filter to successful responses only
Section titled “Filter to successful responses only”-- Only process rows where the API returned 200SELECT url, (response->>'content')::jsonb AS dataFROM my_urlsCROSS JOIN LATERAL (SELECT http_get(my_urls.url) AS response) rWHERE (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:
SELECT m.url, p.statusFROM my_urls m,LATERAL http_probe(m.url) pWHERE p.status = 200;Raise an exception on failure
Section titled “Raise an exception on failure”Use RAISE EXCEPTION inside a function or DO block to abort on non-200:
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:
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.
Common error messages
Section titled “Common error messages”Error messages never include the request URL, host or port, so identify the failing call from the statement itself.
| Error | SQLSTATE | Cause |
|---|---|---|
insecure HTTP requests are disabled | 42501 | Plain http:// URL — use https:// |
only HTTP and HTTPS URL schemes are supported | 22023 | The URL names some other scheme, such as ftp:// |
URL host or address is not allowed by network policy | 42501 | SSRF protection blocked the hostname, or the IP address it names or resolves to (loopback, private, link-local) |
URL port is not allowed by network policy | 42501 | The 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 allowed | 42501 | The URL contains user:pass@ credentials |
could not resolve HTTP resource host | 58030 | DNS lookup for the host failed |
could not connect to HTTP resource | 58030 | The 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) | 58030 | The redirect chain was longer than 3 hops |
HTTP response stream failed | 58030 | The response started but its body was still arriving when the 5-second request timeout expired |
HTTP response exceeds the 1048576-byte limit | 54000 | Response body exceeded the 1 MB limit |
HTTP response is not valid UTF-8 | 58030 | The 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 limit | 54000 | Request body exceeded the 256 KB limit |
http request limit exceeded (max 100 per statement) | 54000 | The statement tried to issue more than 100 http calls |
Safety Boundaries
Section titled “Safety Boundaries”| Limit | Value |
|---|---|
| Protocol | HTTPS only (HTTP blocked) |
| Max requests per statement | 100 |
| Max concurrent requests | 20 in flight per tenant per node (not per statement) |
| Max response size | 1 MB (1048576 bytes) |
| Max request body | 256 KB (262144 bytes) |
| Max redirects | 3 |
| Request timeout | 5 seconds (1-second connect timeout) |
| SSRF protection | Private/internal IP ranges blocked |
These limits are fixed and cannot be raised with session parameters.
Next Steps
Section titled “Next Steps”- HTTP from SQL Guide — Tutorial: call APIs, send webhooks, and enrich data from SQL
- Extensions Overview — Every built-in extension
- Scheduled Jobs with pg_cron — Combine HTTP calls with scheduled jobs