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 id | Provider | Secret field(s) | Non-secret fields |
|---|---|---|---|
resend | Resend REST API | api_key, from_email | — |
slack_webhook | Slack incoming webhook | webhook_url | — |
webhook | Any HTTPS endpoint | auth_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
- Settings → Integrations → Notification channels (
/integrations/notifications). - Click Connect Resend (or Slack / Webhook).
- Fill the form. Password fields are marked accordingly.
- Save. Click Test to verify.
- First connection for a channel is auto-marked default. Subsequent ones can be set as default via the row menu.
CLI
# 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> --yesAdd a notification node to a workflow
Admin UI
- Open the workflow builder.
- Drag Notification from the palette (bell icon).
- 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.
- Priority —
low/normal/high. - Timeout — 1–60 seconds.
- Save and run.
YAML (declarative)
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: 15Templates
- Recipient, subject use regex
{{variable}}substitution — no filters, no conditionals. Resolves against workflow context. - Body uses Jinja2
SandboxedEnvironmentwithStrictUndefined— supports{% if %},{% for %},| upper, etc. Missing variables raisetemplate_undefined, blocking delivery.
Events
Every delivery emits a CloudEvent to the tenant's Redis Stream:
notification.delivered— on 2xx responsenotification.failed— on any error (classified; never raw strings)
Event payload:
{
"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.error | Meaning | Remediation |
|---|---|---|
no_connection | No connection configured for this channel | Create one in Settings / via CLI |
connection_inactive | Connection exists but status != "active" | Re-run test_connection, reconnect |
vault_credential_missing | Vault returned no credential for this connection | Rotate credentials |
rate_limited | Tenant exceeded 60 notifications/minute | Slow down, use for_each with concurrency=1 |
template_undefined | Body references a variable not in context | Check variable names, verify upstream node outputs |
template_resolution_failed | Recipient/subject template error | Same as above |
template_error | Body Jinja2 syntax error | Fix template |
recipient_required | Email channel called without recipient | Add recipient template |
missing_body | Body template empty | Add body |
missing_channel | No channel selected | Pick one |
unknown_channel | Channel not in registry | Typo in channel id |
missing_tenant | Could not resolve tenant_id | Check session_context wiring |
provider_rate_limited | Provider (Resend/webhook target) returned 429 | Back off, check provider quota |
provider_unauthorized | 401/403 from provider | Rotate API key |
provider_5xx | Provider server error | Retry later; check provider status |
provider_4xx | Provider rejected request | Check payload shape |
invalid_payload | Slack returned 200 + non-"ok" body | Check webhook URL + message format |
webhook_revoked | Slack 404 — webhook URL invalid | Rotate Slack webhook URL |
ssrf_blocked | Target URL resolves to blocked network | Check URL, use public IP |
network_error | Connection failed | Provider unreachable |
timeout | Request exceeded timeout_seconds | Increase timeout or check provider latency |
adapter_error | Unexpected failure in adapter code | Check 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
webhookchannel 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.comandhooks.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.ymlthreads it throughdeployment/deploy-prod.shexports it into the container env.env.production.exampledocuments 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
recipientper send). - Resend
X-Priorityheader not yet mapped —priorityfield is informational only. - Resend domain verification is delegated to Resend;
test_connectiondoes not pre-verify sender domains. - No notification history table — execution logs and emitted CloudEvents are the source of truth.