Vercel to Discord integration for alerts
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.

whoami
Ben Ralton — Cloud engineering, Full Stack Development & DevOps
more posts
I built a sourdough planner so I'd stop looking up a recipe every week
I bake every week and used to start each one with a search for a recipe. So I built a planner that works out the mix,…
My shared Postgres cluster went 133 days without a single metric being scraped
Prometheus never scraped the shared CNPG Postgres cluster in my homelab, from the day it was deployed until I went lo…
Claude.ai said no MCP server was found at my URL. It was a 404 on a method I hadn't implemented.
After OAuth succeeded, claude.ai reported no MCP server at my URL while Claude Code connected fine. The server only h…