Alerts: Slack, Discord and webhooks
Hermoso can tell you when something happens on a brand: a render finished, a scheduled post went out, a draft is waiting for you. Point those alerts at a Slack channel, a Discord channel, or any HTTPS endpoint you run (a Zapier, Make or n8n catch hook works too). Each brand has its own three alert channels, and setting them up costs nothing.
Connect a channel
Each alert channel is a URL you paste once.
| Channel | What to paste |
|---|---|
slack | A Slack incoming-webhook URL. It starts with https://hooks.slack.com/. |
discord | A Discord channel webhook (channel settings, Integrations, Webhooks, New Webhook, Copy URL). It starts with https://discord.com/api/webhooks/. |
webhook | Any public https:// URL that should receive a JSON POST per event. Private and internal addresses are refused. |
- In the app: Workspace ▸ Connectors, then the Slack, Discord or Webhook card.
- From an agent or the API:
connect_connectorwith{"provider": "slack", "fields": {"webhookUrl": "https://hooks.slack.com/..."}}(ordiscord, orwebhook).
Before anything is saved, Hermoso sends the URL a hello message and reads the answer. A URL that Slack or Discord says is deleted or revoked is refused and nothing is stored. Any other problem is saved with a note, and the alert is retried.
Alerts are not posts. On Slack and Discord the webhookUrl field is for alerts. Posting your own content to a channel with post_to_slack or post_to_discord uses a separate URL, announceWebhookUrl, or the Hermoso app for that workspace. Pass one per call.
Choose the events
Events come in groups, and each channel chooses its own. A channel you have never configured receives every group except inbox, which you have to name.
| Group | Events | On by default |
|---|---|---|
renders | render.finished, render.failed | Yes |
posts | post.published, post.partial, post.failed | Yes |
approvals | approval.awaiting: autopilot drafts waiting for your review | Yes |
watch | watch.finding: new competitor ads from the competitor watch | Yes |
credits | credits.low | Yes |
leads | lead.created: a new lead-form lead | Yes |
connections | account.connected, account.disconnected | Yes |
inbox | comment.received, message.received: new Facebook and Instagram comments and DMs | No, name it to receive it |
Change a channel's groups with set_alert_events, for example {"provider": "slack", "events": ["renders", "posts"]}, or "all". An empty list keeps the channel connected and sends it nothing. An unknown group name is refused by name and nothing changes. The reply is read back from the saved setting.
The webhook payload
A webhook gets one JSON POST per event, with content-type: application/json and the user agent Hermoso-Alerts/1. The body always has event and at (when it was sent, ISO 8601), plus that event's own fields. The test event looks like this:
{"event": "test", "ok": true, "message": "Test alert from Hermoso.", "at": "2026-11-04T15:02:11.482Z"}
The fields on account.connected, comment.received and message.received are listed under Webhooks on the partners page.
Verify the signature
Deliveries are signed the Standard Webhooks way. The signing secret (it starts with whsec_) is shown once, when you first connect the webhook. Every delivery carries three headers:
webhook-id: the delivery id. It stays the same across retries, so use it to drop a duplicate.webhook-timestamp: Unix seconds.webhook-signature:v1,followed by the base64 HMAC-SHA256 ofid.timestamp.body, keyed with the base64-decoded part of the secret afterwhsec_. During a rotation it holds two signatures separated by a space.
import crypto from 'node:crypto';
function verify(req, rawBody, secret) {
const id = req.headers['webhook-id'];
const ts = req.headers['webhook-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // older than 5 minutes
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const want = 'v1,' + crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');
return String(req.headers['webhook-signature'] || '').split(' ').some((sig) =>
sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)));
}
Sign the raw body exactly as received, before any JSON parsing. To replace the secret, call rotate_webhook_signing_secret: the new secret is returned once, and the old one keeps signing alongside it for 24 hours so your receiver has a day to switch.
Delivery and retries
- Each attempt waits up to 10 seconds and does not follow redirects.
- A
2xxanswer is delivered. - A
5xx,408,425,429, a timeout or a dropped connection is retried, up to seven attempts over about 16 hours. - Any other
4xxrefuses that one event, which is not retried. The channel stays on. - A channel that is gone stops. That is Slack saying the webhook was removed or the channel archived, Discord answering that the webhook is unknown or its token invalid, or your endpoint answering
410 Gone. Nothing more is sent until you paste a new URL.
Check and test
list_alert_settings shows each channel: connected or not, the groups it receives, its health (delivering, not delivering, or stopped and waiting for a new URL), whether the webhook is signed, and the recent deliveries with their HTTP status and any retry queued. send_test_alert sends a test to one channel or to all of them and reports what each answered. A test that gets through also clears a not-delivering or stopped state. All four tools are free.