Skip to content

Notification channels

Workflows can send email (Resend), Slack, or arbitrary HTTP webhook notifications via a notification node. This guide walks through setup, use, and troubleshooting.

Issue: #116. Spec: dev_docs/specs/116-notification-node.md.

Supported channels

Channel idProviderSecret field(s)Non-secret fields
resendResend REST APIapi_key, from_email
slack_webhookSlack incoming webhookwebhook_url
webhookAny HTTPS endpointauth_value (optional)url, auth_header

All secret fields are stored in Vault. Non-secret fields are stored in connections.config (Postgres). The admin UI and CLI enforce this routing — attempts to send secrets through config return 400.

Set up a connection

Admin UI

  1. Settings → Integrations → Notification channels (/integrations/notifications).
  2. Click Connect Resend (or Slack / Webhook).
  3. Fill the form. Password fields are marked accordingly.
  4. Save. Click Test to verify.
  5. First connection for a channel is auto-marked default. Subsequent ones can be set as default via the row menu.

CLI

bash
# List available channel types
martha notifications channels

# Create a Resend connection (credentials as JSON dict)
martha notifications create \
  --channel resend \
  --name transactional \
  --credential-value '{"api_key":"re_xxxxx","from_email":"ops@example.com"}'

# From file
martha notifications create --channel slack_webhook --name ops-alerts \
  --credential-value @slack-creds.json

# From stdin
echo '{"api_key":"re_xxx","from_email":"ops@example.com"}' | \
  martha notifications create --channel resend --name prod --credential-value -

# List, test, delete
martha notifications connections --channel resend
martha notifications test <connection-id>
martha notifications delete <connection-id> --yes

Add a notification node to a workflow

Admin UI

  1. Open the workflow builder.
  2. Drag Notification from the palette (bell icon).
  3. Select:
    • Channel (resend / slack_webhook / webhook)
    • Connection (defaults to tenant default for that channel)
    • Recipient — email address (email channels only). Supports {{variable}}.
    • Subject — optional subject line (email only).
    • Body — full Jinja2 supported. Missing variables raise an error.
    • Prioritylow / normal / high.
    • Timeout — 1–60 seconds.
  4. Save and run.

YAML (declarative)

yaml
nodes:
  - id: alert
    type: notification
    config:
      channel: resend
      connection_name: transactional   # optional; uses default if omitted
      recipient: "{{ inputs.ops_email }}"
      subject: "SLA breach on {{ inputs.asset_id }}"
      body: |
        Asset {{ inputs.asset_id }} exceeded SLA.
        {% if inputs.severity == "critical" %}
        Requires immediate attention.
        {% endif %}
      priority: high
      timeout_seconds: 15

Templates

  • Recipient, subject use regex {{variable}} substitution — no filters, no conditionals. Resolves against workflow context.
  • Body uses Jinja2 SandboxedEnvironment with StrictUndefined — supports {% if %}, {% for %}, | upper, etc. Missing variables raise template_undefined, blocking delivery.

Events

Every delivery emits a CloudEvent to the tenant's Redis Stream:

  • notification.delivered — on 2xx response
  • notification.failed — on any error (classified; never raw strings)

Event payload:

json
{
  "channel": "resend",
  "node_id": "alert",
  "execution_id": "exec_abc",
  "priority": "high",
  "delivery_id": "re_xxx",
  "recipient_hash": "<hmac_sha256>",
  "error": null,
  "status_code": 200
}

Recipient addresses are hashed via HMAC-SHA256 with NOTIFICATION_HASH_SECRET (set in env). Never logged plaintext.

Error classification

Downstream workflow nodes (e.g., choice) can branch on nodes.<node_id>.output.status ("delivered" / "failed") and nodes.<node_id>.output.error:

output.errorMeaningRemediation
no_connectionNo connection configured for this channelCreate one in Settings / via CLI
connection_inactiveConnection exists but status != "active"Re-run test_connection, reconnect
vault_credential_missingVault returned no credential for this connectionRotate credentials
rate_limitedTenant exceeded 60 notifications/minuteSlow down, use for_each with concurrency=1
template_undefinedBody references a variable not in contextCheck variable names, verify upstream node outputs
template_resolution_failedRecipient/subject template errorSame as above
template_errorBody Jinja2 syntax errorFix template
recipient_requiredEmail channel called without recipientAdd recipient template
missing_bodyBody template emptyAdd body
missing_channelNo channel selectedPick one
unknown_channelChannel not in registryTypo in channel id
missing_tenantCould not resolve tenant_idCheck session_context wiring
provider_rate_limitedProvider (Resend/webhook target) returned 429Back off, check provider quota
provider_unauthorized401/403 from providerRotate API key
provider_5xxProvider server errorRetry later; check provider status
provider_4xxProvider rejected requestCheck payload shape
invalid_payloadSlack returned 200 + non-"ok" bodyCheck webhook URL + message format
webhook_revokedSlack 404 — webhook URL invalidRotate Slack webhook URL
ssrf_blockedTarget URL resolves to blocked networkCheck URL, use public IP
network_errorConnection failedProvider unreachable
timeoutRequest exceeded timeout_secondsIncrease timeout or check provider latency
adapter_errorUnexpected failure in adapter codeCheck Martha logs

Rate limits

Per-tenant default: 60 notifications/minute via Redis sliding window (notification:rate:{tenant_id}). Exceeds → output.error="rate_limited", HTTP 429 in emitted event.

Separate bucket from webhook rate limiter — a burst of inbound webhooks does not throttle outbound notifications.

Security

  • SSRF protection — generic webhook channel blocks loopback, RFC1918, CGN (100.64.0.0/10), link-local (IPv4+IPv6), unique-local (fc00::/7), IPv4-mapped IPv6 (::ffff:). Validated at both create and send time.
  • Slack URL allowlist — only hooks.slack.com and hooks.slack-gov.com (exact match, trailing-dot safe).
  • Credential scrubbing — adapter errors return classified codes only. Slack URLs, Resend API keys, and webhook auth values never appear in output.error, logs, or events.
  • PII — recipient addresses hashed (HMAC-SHA256) before emission. Subject and body bodies are NOT included in events.

Deployment

Set NOTIFICATION_HASH_SECRET (long random string, ≥32 bytes) in production:

  • GitHub secret NOTIFICATION_HASH_SECRET
  • .github/workflows/deploy.yml threads it through
  • deployment/deploy-prod.sh exports it into the container env
  • .env.production.example documents it

If unset, the activity logs a warning and falls back to a known default — recipient hashes become predictable.

Limitations / follow-ups

  • No in-adapter retry on 5xx (workflow authors can wrap in a retry node).
  • No multi-recipient support (one recipient per send).
  • Resend X-Priority header not yet mapped — priority field is informational only.
  • Resend domain verification is delegated to Resend; test_connection does not pre-verify sender domains.
  • No notification history table — execution logs and emitted CloudEvents are the source of truth.

Martha is built by aiaiai-pt.