Skip to content

Slack Endpoints

Slack delivery rides the webhook transport: the pg_relay Processor (≥ 1.2) POSTs a JSON body — built entirely by this extension — to Slack's chat.postMessage API, and hands the response back to this extension to classify. The Processor knows nothing about Slack specifically; the provider adapter (provider = 'slack') lives in SQL, in this extension.

One-time Slack setup

  1. Create a Slack app at api.slack.com/appsCreate New App → From scratch, in your workspace.
  2. Add the bot scope: OAuth & Permissions → Scopes → Bot Token Scopeschat:write.
  3. Install the app to the workspace (same page). Copy the Bot User OAuth Token (xoxb-...).
  4. Export the token on the Processor host (and only there):

    export SLACK_BOT_TOKEN='xoxb-...'
    

    The token is never stored in the database — the profile references it as "_env:SLACK_BOT_TOKEN", resolved by the Processor from its own environment at send time, exactly like SMTP passwords and M365 client secrets (see SMTP Endpoints for the full rationale). The Processor reads its environment at start, so rotating the token means restarting the Processor.

  5. Invite the bot to each target channel (/invite @your-app in Slack) — a bot can only post where it is a member; otherwise sends fail with not_in_channel.

The profile

SELECT pgrelay_notifier.create_channel('slack');   -- or reuse an existing notify channel

SELECT pgrelay_notifier.create_profile(
    p_profile_name => 'slack_bot',
    p_transport    => 'webhook',
    p_provider     => 'slack',
    p_profile      => '{
        "url":  "https://slack.com/api/chat.postMessage",
        "auth": {"style": "bearer_header", "secret": "_env:SLACK_BOT_TOKEN"}
    }'::jsonb,
    p_channel      => 'slack'
);
Key Required Meaning
url https://slack.com/api/chat.postMessage
auth.style bearer_header — the Processor sends Authorization: Bearer <token>
auth.secret Must be an "_env:VAR_NAME" reference to the bot token — a literal value is rejected
body_merge Optional, generic to the webhook transport (not needed for Slack): a JSON object overlaid onto the request body at send time — see The body_merge overlay below
timeout_seconds Default 30, cap 120

p_provider is required for webhook profiles: it selects the provider adapter — the pair of functions that build the request body and interpret the response. Supported today: 'slack', plus 'resend', 'telnyx', and 'pagerduty' (their own page); the transport itself is generic, and new providers are added by an extension release with no Processor change.

Senders then post with ordinary send()/compose() calls, using the Slack channel id as the recipient — formatting, Block Kit layouts, and threading are covered in Sending to Slack in the User Guide.

The body_merge overlay (generic webhook feature)

Slack doesn't need it, but the webhook transport carries one more optional profile key, for providers that authenticate inside the request body rather than in a header: body_merge, a JSON object whose top-level keys the Processor overlays onto the request body immediately before sending (pg_relay ≥ 1.2).

  • Values may be _env:VAR_NAME references. Because body_merge lives in the profile, its string values are resolved from the Processor host's environment exactly like auth.secret — which is the whole point: a body-carried credential (PagerDuty's routing_key, Vonage's legacy SMS api_key/api_secret) never enters the database.
  • Merged keys always win. A producer-supplied value for the same top-level key is silently overwritten. The key is profile-owned, so a sender can never inject, spoof, or occupy it — this is a security property, not a default.
  • Shape-blind. Top-level key assignment only — no recursive merge, no inspection or interpretation of the message; values of any JSON type pass through verbatim.
  • Validated up front. create_profile()/update_profile() accept a body_merge of JSON type object and reject any other type with a clear message. The Processor treats a non-object body_merge as a permanent failure with no request sent — exactly the malformed-auth contract — so validation front-runs what would otherwise fail at send time.

The motivating example, PagerDuty Events API v2 (provider adapter still on the roadmap):

// profile — the routing key lives on the Processor host, not in the database
{
  "url": "https://events.pagerduty.com/v2/enqueue",
  "auth": {"style": "bearer_header", "secret": "unused"},
  "body_merge": {"routing_key": "_env:PD_ROUTING_KEY"},
  "timeout_seconds": 30
}
// request body rendered by the adapter — no routing_key anywhere;
// the profile injects it at send time
{"event_action": "trigger", "dedup_key": "pgrelay-33", "payload": {"...": "..."}}

Full Processor-side semantics are in WEBHOOK_TRANSPORT_PROCESSOR_SPEC.md §2.1 in the repository.

How responses are classified

Slack answers HTTP 200 for both success and failure, distinguishing in the JSON body — the adapter handles that quirk:

Slack's response Outcome
200 with "ok": true sent — the message timestamp (ts) is recorded as provider_ref (also the handle for threading replies)
200 with "ok": false failed, with the Slack error in status_detail (channel_not_found, not_in_channel, invalid_auth, msg_too_long, invalid_blocks, ...)
429 retry (Slack rate limit; the Retry-After header is quoted in the detail)
5xx, or no response at all retry
Anything else failed

Retries follow the channel's max_retries and backoff exactly as for email — see Channels and Concurrency.

Things worth knowing

  • not_in_channel is the most common first failure — invite the bot to the channel.
  • invalid_auth means the token is wrong, revoked, or the env var is unset/misspelled on the Processor host (an unset _env: variable is itself a clean permanent failure naming the variable).
  • Posting to a user instead of a channel works with the user's member id (U...) if the app has im:write; the adapter passes whatever id the sender supplies straight through as channel.
  • Slack rate-limits chat.postMessage per channel (roughly one message per second, in bursts). A busy alert channel is a good candidate for concurrency_mode = 'channel' so 429-retries stay ordered.
  • File attachments added with attach() are not delivered to Slack — chat.postMessage cannot carry them.
  • The debug trace (Debug Tracing) additionally records the HTTP status of each webhook attempt.