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.

Need to run multiple statements as an atomic unit (BEGIN / COMMIT, conditional rollback, variable passing between steps)? See Postgres Transactions — same node, transaction mode toggle.

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.

Trigger / upstream--▶Postgres Action--▶{ rowCount, rows: [...] }--▶Downstream (iterates rows)
Iterable output. When the operation returns rows, the node marks its output { _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:

textConnection string format
postgres://user:password@host:5432/database?sslmode=require

For 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.

OperationUse forOutput shapeIterable downstream
querySELECT{ rowCount, rows: [...] }Yes
insertOneINSERT (use RETURNING *){ rowCount, rows: [...] }Yes
updateUPDATE (use RETURNING ...){ rowCount, rows: [...] }Yes
deleteDELETE (use RETURNING ...){ rowCount, rows: [...] }Yes
executeDDL, multi-statement, anything else{ rowCount } or { rowCount, rows } if returnRows is onOnly if returnRows on
Pick the operation that matches the verb. 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.

sqlAuto-bind — what you write
SELECT id, email, plan
FROM users
WHERE org_id = {{payload.orgId}}
  AND created_at > {{payload.since}}
ORDER BY created_at DESC
LIMIT {{payload.limit}}
sqlWhat Postgres actually receives (over the wire)
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.

SQL injection — guarded by construction. The placeholder text is replaced with $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 valueBind value typePostgres column types it works for
"hello"stringtext, varchar, uuid (with cast)
42numberint, bigint, numeric, real
true / falsebooleanboolean
{ a: 1, b: "x" }objectjsonb, json (auto-serialized by node-postgres)
[1, 2, 3]arrayjsonb, json, or pg arrays (with cast)
null / undefinednullany 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.

sqlSQL field
SELECT * FROM users WHERE id = $1 AND org = $2
jsonParameters field — JSON array template
["{{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

sqlSQL (operation: query)
SELECT id, email, plan
FROM users
WHERE org_id = {{payload.orgId}}
  AND active = true
ORDER BY last_seen DESC
LIMIT 50
jsonOutput
{
  "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

sqlSQL (operation: insertOne)
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

sqlSQL (operation: update)
UPDATE rate_limits
SET hit_count = hit_count + 1,
    last_hit_at = NOW()
WHERE api_key_id = {{payload.apiKeyId}}
RETURNING hit_count

DELETE — purge by id

sqlSQL (operation: delete)
DELETE FROM sessions
WHERE user_id = {{payload.userId}}
  AND expires_at < NOW()
RETURNING id

EXECUTE — DDL or batch script

sqlSQL (operation: execute, returnRows: false)
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

sqlSQL — query nested JSON
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 DESC

Tunnel 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:

  1. Run the agent locally: hostwh expose-tcp --target=localhost:5432 --id=prod-pg (or the equivalent docker run with HOSTWH_TARGET=host.docker.internal:5432).
  2. Confirm in Settings → Tunnels that prod-pg shows online.
  3. On the Postgres credential, pick prod-pg from the tunnel selector.
  4. Test connection — the node now routes through the tunnel forwarder transparently.
The connection string can use private hosts (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:

textPipeline
Webhook  →  Postgres Action (query: list users)  →  Notification Action

If 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

FieldDefaultDescription
statementTimeoutMs10000Postgres-side timeout (sets statement_timeout on the session). Caps long-running queries — the node fails fast and releases the connection. Range: 100–300000 ms.
returnRowsfalseOnly applies to operation: execute. When on, output includes the rows array; when off, just rowCount. Other operations always return rows.
triggerOnsuccessPipeline-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.
isActivetruePause 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

bashCreate a Postgres action
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>"
  }'
bashTest execute
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 } }'
Auto-bind, tunnel routing, JSONB type preservation, and prepared statements all happen identically whether you trigger via the canvas Test button, the API directly, or a real pipeline run. The only difference is whether telemetry events are tagged trigger: 'pipeline_test' or the regular pipeline trigger.