PostgreSQL Action
Run SQL queries against your Postgres database from a flow. Five operations (query / insertOne / update / delete / execute), auto-bind of {{payload.x}} placeholders into prepared statements (no SQL injection by construction), JSONB columns round-trip as objects, and full support for the reverse TCP tunnel if your DB lives on private infra.
Overview
The PostgreSQL Action node is a chainable action — it takes the current pipeline payload, builds a parameterized SQL query, executes it, and emits the result downstream. Every run goes through node-postgres's client.query({ text, values }) form so the SQL and the values travel as separate Postgres protocol messages — the values are never concatenated into the query text.
{ _meta: { iterable: true, iterateField: 'rows' } }. Downstream nodes like Notification or HTTP Action run once per row. Useful for "fetch matching users from DB → send each one an email."Setup — three steps
1. Create a Postgres credential
Settings → Credentials → + New Credential. Pick PostgreSQL Connection as the type and paste your connection string:
postgres://user:password@host:5432/database?sslmode=requireFor Railway / Neon / Supabase / RDS, copy the value Railway calls DATABASE_PUBLIC_URL (or the equivalent). The connection string is encrypted at rest with AES-256-GCM.
If your Postgres lives on private infra (a homelab, a VPC, a Mac on your desk), instead expose it via hostwh expose-tcp --target=localhost:5432 or the Docker agent (ghcr.io/hostwebhook/agent), then attach metadata.tunnelId to the credential. The node will route through the tunnel automatically — see the Tunnel routing section below.
2. Test the credential
Click Test connection on the credential page. A green ✓ confirms HostWebhook can reach your DB and reports back the Postgres version + the database name auto-detected from the URI's path component.
3. Drop a Postgres Action onto the canvas
Open any flow, drag Postgres Action from the sidebar, click into the node to open its detail page, pick the credential you just created, choose an operation, and write your SQL. Save, hit Test with a sample payload, and you're connected.
Operations
Five operations cover the full SQL surface. The choice mainly affects the output shape — under the hood every operation runs the same client.query({ text, values }) call.
| Operation | Use for | Output shape | Iterable downstream |
|---|---|---|---|
| query | SELECT | { rowCount, rows: [...] } | Yes |
| insertOne | INSERT (use RETURNING *) | { rowCount, rows: [...] } | Yes |
| update | UPDATE (use RETURNING ...) | { rowCount, rows: [...] } | Yes |
| delete | DELETE (use RETURNING ...) | { rowCount, rows: [...] } | Yes |
| execute | DDL, multi-statement, anything else | { rowCount } or { rowCount, rows } if returnRows is on | Only if returnRows on |
query through delete share identical execution but signal intent in audit logs and downstream meta. execute exists for "I'm running DDL or a multi-statement script and don't want row data unless I ask."Auto-bind: write SQL the way you read it
The recommended way to use the node: write {{payload.field}} directly inside your SQL. At execute time, each placeholder is replaced with $1, $2, …, the values are resolved against the incoming payload, and the result is sent as a prepared statement.
SELECT id, email, plan
FROM users
WHERE org_id = {{payload.orgId}}
AND created_at > {{payload.since}}
ORDER BY created_at DESC
LIMIT {{payload.limit}}SELECT id, email, plan
FROM users
WHERE org_id = $1
AND created_at > $2
ORDER BY created_at DESC
LIMIT $3
-- values: ["6640a1b2c3d4e5f6", "2026-04-01T00:00:00Z", 50]Leave the Parameters field empty (or []) to use auto-bind. The order of the placeholders in the SQL drives the order of $N bindings — repeated placeholders get a new $N each time, so you can reference the same payload field twice in a WHERE clause without manually counting.
$N before the payload values are resolved. Values never get concatenated into the SQL string. A payload like { "userId": "1; DROP TABLE users--" } becomes the bind value for $1, and Postgres treats it as a literal — never as SQL. The same guarantee you get from node-postgres's parameterized queries directly.Type preservation
Auto-bind preserves the JS type of each placeholder:
| Payload value | Bind value type | Postgres column types it works for |
|---|---|---|
| "hello" | string | text, varchar, uuid (with cast) |
| 42 | number | int, bigint, numeric, real |
| true / false | boolean | boolean |
| { a: 1, b: "x" } | object | jsonb, json (auto-serialized by node-postgres) |
| [1, 2, 3] | array | jsonb, json, or pg arrays (with cast) |
| null / undefined | null | any nullable column |
Concretely, this means you can write INSERT INTO events (data) VALUES ({{payload.data}}) and if the column is JSONB and payload.data is an object, it round-trips losslessly. No quoting, no escaping, no explicit cast.
Manual bind mode (advanced)
If you prefer the classic prepared-statement style — write $1, $2 in the SQL and supply the values yourself — fill the Parameters field with a JSON-array template. The presence of any non-empty array switches the node into manual mode and disables auto-extraction from the SQL.
SELECT * FROM users WHERE id = $1 AND org = $2["{{payload.userId}}", "{{payload.orgId}}"]Useful when you want fine control over the bind order, when you re-use the same parameter at multiple positions in the SQL, or when porting an existing query template directly.
Examples
SELECT — fetch matching rows
SELECT id, email, plan
FROM users
WHERE org_id = {{payload.orgId}}
AND active = true
ORDER BY last_seen DESC
LIMIT 50{
"rowCount": 3,
"rows": [
{ "id": 101, "email": "[email protected]", "plan": "pro" },
{ "id": 99, "email": "[email protected]", "plan": "free" },
{ "id": 92, "email": "[email protected]", "plan": "free" }
]
}INSERT with JSONB — store webhook data
INSERT INTO webhook_events (event_type, payload, source_ip)
VALUES ({{payload.type}}, {{payload}}, {{meta.sourceIp}})
RETURNING id, created_at{{payload}} alone (no field) gives you the whole event payload as a single object — perfect for archiving the raw event into a JSONB column.UPDATE — increment a counter
UPDATE rate_limits
SET hit_count = hit_count + 1,
last_hit_at = NOW()
WHERE api_key_id = {{payload.apiKeyId}}
RETURNING hit_countDELETE — purge by id
DELETE FROM sessions
WHERE user_id = {{payload.userId}}
AND expires_at < NOW()
RETURNING idEXECUTE — DDL or batch script
CREATE TABLE IF NOT EXISTS user_sync_log (
id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
synced_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_synced_at ON user_sync_log(synced_at);Output for execute with returnRows: false is just { rowCount: 0 } — the node doesn't try to iterate downstream, which is what you want for setup scripts.
JSONB query — filter on nested fields
SELECT id,
payload->>'orderId' AS order_id,
payload->'customer'->>'email' AS customer_email,
(payload->>'amount')::numeric AS amount
FROM webhook_events
WHERE payload->>'eventType' = {{payload.eventType}}
AND (payload->>'amount')::numeric > {{payload.minAmount}}
ORDER BY created_at DESCTunnel routing — DBs on private infra
Postgres on a homelab, a Mac on your desk, a private VPC, or a Railway service in a different project — anything HostWebhook can't reach over the public internet. Same pattern as the MongoDB credential flow:
- Run the agent locally:
hostwh expose-tcp --target=localhost:5432 --id=prod-pg(or the equivalentdocker runwithHOSTWH_TARGET=host.docker.internal:5432). - Confirm in Settings → Tunnels that
prod-pgshows online. - On the Postgres credential, pick
prod-pgfrom the tunnel selector. - Test connection — the node now routes through the tunnel forwarder transparently.
localhost:5432, 192.168.x.x, etc.) when a tunnel is attached — the SSRF guard recognizes the tunneled credential and skips the check, since "localhost" inside the tunnel is the customer's private network, not HostWebhook infrastructure.Connecting downstream — iterate over rows
Operations that return rows mark their output as iterable: true with iterateField: 'rows'. Any downstream node runs once per row. The downstream node receives a single row as its payload:
Webhook → Postgres Action (query: list users) → Notification ActionIf the query returns 3 rows, the Notification Action fires 3 times — once per user — with each iteration's payload being the row { id, email, plan }. Inside the Notification template, reference fields directly: {{payload.email}}.
To not iterate (treat the whole result as one unit downstream — say, count and report), use operation: execute with returnRows: false. Output is just { rowCount } and downstream runs once.
Other settings
| Field | Default | Description |
|---|---|---|
| statementTimeoutMs | 10000 | Postgres-side timeout (sets statement_timeout on the session). Caps long-running queries — the node fails fast and releases the connection. Range: 100–300000 ms. |
| returnRows | false | Only applies to operation: execute. When on, output includes the rows array; when off, just rowCount. Other operations always return rows. |
| triggerOn | success | Pipeline-level filter: success (run only when upstream succeeded), always (run regardless). Useful for "log every event whether it delivered or not" patterns paired with INSERT. |
| filters | [] | Standard payload-shape filters (same as every other action node). Skips the SQL execution when filters don't match. |
| isActive | true | Pause without deleting. Pipeline runs skip the node while keeping its config intact. |
Common errors
"connection refused"
The host in the connection string isn't reachable from HostWebhook. If it's a private host, use a tunnel. If it's public, check the Postgres server allows connections from Railway's egress IPs (most managed services like Neon, Supabase, Railway Postgres do by default).
"password authentication failed"
Connection string credentials are wrong, or the password contains special characters that need URL-encoding. Replace @, %, ?, and # with their percent-encoded forms in the password section before pasting.
"null value in column ... violates not-null constraint"
Postgres-side schema requires a column you didn't include. Two options: include the column in the INSERT (e.g. VALUES (..., NOW()) for created_at), or run an ALTER on the table to give the column a default: ALTER TABLE x ALTER COLUMN y SET DEFAULT NOW();.
"directConnection must be either true or false"
Typo in the connection string — the value got truncated or copy-pasted with a missing character. Verify the full URL round-trips through a manual psql first.
"Failed to resolve {{...}} placeholders in SQL"
A placeholder couldn't resolve to a value (typo in the path, missing field on the payload). Compare your SQL placeholders against the test payload carefully — the node uses the exact path you wrote, no fuzzy matching.
REST API
curl -X POST $API_BASE/api/postgres-actions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Log webhook to Postgres",
"credentialId": "<credential-id>",
"operation": "insertOne",
"sqlTemplate": "INSERT INTO webhook_events (event_type, payload) VALUES ({{payload.type}}, {{payload}}) RETURNING id, created_at",
"paramsTemplate": "[]",
"statementTimeoutMs": 10000,
"triggerOn": "success",
"isActive": true,
"workspaceId": "<workspace-id>"
}'curl -X POST $API_BASE/api/postgres-actions/<id>/test \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "payload": { "type": "user.created", "userId": 42 } }'trigger: 'pipeline_test' or the regular pipeline trigger.