Skip to content
all posts

Vercel to Discord integration for alerts

4 min readSubscribe via RSS

I was setting up a spend budget on Vercel and realised the alerts were never going to make it to Discord. Spend Management can email you, text you at 100%, or POST to a webhook URL. That's it. Vercel has a Slack integration for deployment notifications, and nothing for Discord.

A webhook URL is enough though. Vercel will POST its own JSON to any public URL, and a Discord webhook will accept a message from anyone who has the URL, as long as the body is a Discord message and not Vercel's. So the gap is one function that takes one and produces the other. That's vercel-discord-alerts: a single Vercel Function with no framework and no runtime dependencies. It checks the signature on the delivery, builds one embed per event, and posts it.

It handles the team webhook events as well as the spend one, since it's the same shape of problem.

Event What you get in Discord
deployment.error red, with project, environment, branch, commit, author and a link to the deployment
deployment.canceled, deployment.rollback amber, same details
Spend Management threshold amber at 75%, red at 100%, with the budget and current spend
alerts.triggered red, with the alert name and payload
firewall.attack red, with the project and payload
anything else grey, with the event type and a JSON snippet

Unknown event types still get posted, as that grey message with a snippet, so a new type doesn't disappear silently.

Creating the webhook

Both account webhooks and Spend Management are Pro and Enterprise only, so none of this applies on Hobby.

Vercel signs every delivery: x-vercel-signature is the hex HMAC-SHA1 of the raw body under a secret that's shown once, when the webhook is created. The CLI docs say vercel webhooks create displays it. For me it didn't. The webhook got created and the secret wasn't in the output. If I'd made it in the dashboard I'd have got it, but I wanted to use the CLI, so I went through the REST API instead, which the CLI can call directly:

printf '%s' '{"url":"https://<your-deployment>/api/vercel","events":["deployment.error","deployment.canceled","deployment.rollback","alerts.triggered","firewall.attack"]}' \
  | vercel api /v1/webhooks -X POST --input -

The response includes secret. Those five event names are the ones the API accepted. The reference list is much longer, and some of the names on it come back as invalid webhook event names when you try to create an account webhook with them, so expect to trim your list down.

Spend Management is set up separately, under Billing, with the same URL. It has its own secret, shown when you save. So the function takes VERCEL_WEBHOOK_SECRETS as a comma-separated list and tries each one.

Not losing deliveries

If the endpoint doesn't answer 2xx, Vercel retries with exponential backoff for up to 24 hours, then drops the event. The function uses that rather than answering 200 to everything:

  • Discord accepts the message: 200.
  • Discord is unavailable (429 or 5xx): 502, and Vercel retries later.
  • Discord refuses the message (any other 4xx): log it and answer 200, because the same message will be refused again and there's no point retrying it for a day.
  • Bad signature: 401. Without both environment variables set: 503, so a copy deployed from the public repo can't be pointed at anyone's channel.

Vercel sends some events twice

During testing a cancelled deployment produced two deployment.canceled events, with different event ids, a second apart. Two Discord messages for one cancel.

The fix is a map in the function's memory, keyed on event type plus deployment id. A repeat within 10 minutes is answered 200 with duplicate: true and not posted. The memory is per warm function instance, so a pair that lands on two different instances will still post twice. I've accepted that. The pairs arrive close enough together that they almost always hit the same instance, and the alternative is adding a store to a function that has no dependencies on purpose.

I only saw it on cancels. I haven't checked whether every event type does it.