Webhooks, Slack and Teams
Tell your own systems, Slack or Microsoft Teams when links change, reach a milestone or lead to a conversion.
Business plan. A webhook tells another system when something happens in your workspace: a link is created, changed or deleted, a link reaches a number of clicks, a conversion comes in, or a custom domain starts or stops working. Send events to your own code, to tools such as Zapier or Make, or as short messages to a Slack or Microsoft Teams channel.
How it works
- You add a webhook: an https address, and the events it should receive.
- When one of those events happens, we post it to the address, usually within a few seconds.
- Your server answers with any 2xx status, such as
200 OK, to say it got it. - If it doesn't, we try again later, for about a day. You can see every attempt in the app.
Only workspace Administrators and the Owner can manage webhooks. Each workspace can have up to 10.
Add a webhook
- Open Webhooks in the app.
- Choose where to send events: your own system (signed JSON), a Slack channel or a Microsoft Teams channel.
- Paste the address, tick the events you want and, for click milestones, list the numbers of clicks, such as
100, 1000. - Select Add webhook. For JSON webhooks, copy the signing secret now: it's shown once.
- Open the webhook and select Send test event. Refresh after a few seconds to see your server's answer under Recent deliveries.
You can do the same with the webhooks API.
Events
| Event | When it's sent | What's in data |
|---|---|---|
link.created | A link was created, in the app, through the API or by an import. | link: the link as the API returns it. |
link.updated | A link's destination, state, schedule, title, tags or other settings changed. | link: the link after the change; changes: what changed, such as "destination". |
link.deleted | A link was deleted. | link: the link as the API returns it. |
link.click_threshold | A link reached a number of clicks from people that you choose, such as 100 or 1,000. Sent once per link and number. | link (id, shortUrl, destinationUrl, title); threshold: the milestone; humanClicks: the link's total so far. |
link.health_changed | A link's destination was confirmed broken, unreachable or unsafe, changed from one problem to another, or started working again. | domain: id, hostname, status, canServeLinks and lastCheckMessage. |
conversion.recorded | Your site reported a sign-up, sale or other conversion for a click. | conversion: as the conversions API returns it; link: id and shortUrl. |
domain.verified | A custom domain passed its DNS checks and started serving links. | domain: id, hostname, status, canServeLinks and lastCheckMessage. |
domain.failed | A custom domain stopped serving links because its DNS records are missing or wrong. | domain: id, hostname, status, canServeLinks and lastCheckMessage. |
ping | When you select Send test event. Every webhook receives it. | message, workspaceName and webhookId. |
Click milestones count clicks from people, not bots or link previews, and are checked as clicks are processed, usually within a minute. Each milestone is sent once per link. A link that had already passed a milestone when you added the webhook isn't sent it.
What we send
Every JSON event has the same envelope: id (the same on every retry), type, version (1), createdAt, workspaceId and data. We may add fields; ignore ones you don't recognise.
A request we send (JSON format)
POST /hooks/blackgum HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: BlackgumLinks-Webhooks/1.0
Blackgum-Event-Id: evt_3kQ9zV1mXw7TbR2aYpLc
Blackgum-Event-Type: link.click_threshold
Blackgum-Delivery-Id: dQ4nT8wLp2Zs
Blackgum-Signature: t=1760490729,v1=6f1c0e3d1b8c9a…
{
"id": "evt_3kQ9zV1mXw7TbR2aYpLc",
"type": "link.click_threshold",
"version": 1,
"createdAt": "2026-10-15T01:12:08.5521+00:00",
"workspaceId": "Wk2pX8sQa1Lm",
"data": {
"link": {
"id": "ZiZihoWXe3as",
"shortUrl": "https://bgum.me/spring-sale",
"domain": "bgum.me",
"alias": "spring-sale",
"destinationUrl": "https://example.com/spring-sale",
"title": "Spring sale"
},
"threshold": 1000,
"humanClicks": 1003
}
}
- Answer within 10 seconds. If you need longer, store the event and answer first.
- We don't follow redirects. Use the final address.
- Events can arrive more than once and not always in order. Use
idto ignore repeats, andcreatedAtto order them.
Check the signature
Each JSON request has a Blackgum-Signature header such as t=1760490729,v1=6f1c…. t is when we signed it, in Unix seconds. v1 is the HMAC-SHA256, in lower-case hex, of t, a full stop and the raw request body, using the whole signing secret (including whsec_) as the key.
- Read the raw body before parsing it: the signature covers the exact bytes.
- Work out the HMAC yourself and compare it with
v1using a constant-time comparison. - Reject the request if
tis more than 5 minutes from your clock, so an old request can't be replayed.
Node.js
// Express: keep the raw body, because the signature covers the exact bytes we sent.
const crypto = require("crypto");
const express = require("express");
const app = express();
const SECRET = process.env.BLACKGUM_WEBHOOK_SECRET; // whsec_…
app.post("/hooks/blackgum", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Blackgum-Signature") || "";
const parts = Object.fromEntries(header.split(",").map(p => p.split("=", 2)));
const body = req.body.toString("utf8");
const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.${body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const ok = fresh && parts.v1 && parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(body);
// Ignore repeats: we may send the same event.id more than once.
console.log(event.type, event.data);
res.sendStatus(200);
});
Python
# Flask
import hashlib, hmac, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["BLACKGUM_WEBHOOK_SECRET"].encode() # whsec_…
@app.post("/hooks/blackgum")
def blackgum():
body = request.get_data() # the raw bytes, before any JSON parsing
header = request.headers.get("Blackgum-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
expected = hmac.new(SECRET, parts.get("t", "").encode() + b"." + body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts.get("t", "0"))) < 300
if not (fresh and hmac.compare_digest(parts.get("v1", ""), expected)):
abort(401)
event = json.loads(body)
# Ignore repeats: we may send the same event["id"] more than once.
print(event["type"], event["data"])
return "", 200
C#
// ASP.NET Core minimal API
using System.Security.Cryptography;
using System.Text;
var secret = Environment.GetEnvironmentVariable("BLACKGUM_WEBHOOK_SECRET")!; // whsec_…
app.MapPost("/hooks/blackgum", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var body = await reader.ReadToEndAsync();
var parts = request.Headers["Blackgum-Signature"].ToString().Split(',')
.Select(p => p.Split('=', 2)).Where(p => p.Length == 2).ToDictionary(p => p[0], p => p[1]);
if (!parts.TryGetValue("t", out var t) || !parts.TryGetValue("v1", out var v1)) return Results.Unauthorized();
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t)) > 300) return Results.Unauthorized();
var key = Encoding.UTF8.GetBytes(secret);
var expected = Convert.ToHexStringLower(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{t}.{body}")));
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(v1), Encoding.ASCII.GetBytes(expected)))
return Results.Unauthorized();
// Ignore repeats: we may send the same event id more than once.
return Results.Ok();
});
To replace a secret, open the webhook and select Replace signing secret. The old secret stops working straight away.
Retries and failures
A delivery fails if your server doesn't answer within 10 seconds or answers with anything other than a 2xx status. We try again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 15 hours: 7 tries over about a day.
- Recent deliveries on the webhook's page shows each event, the status code, how long your server took and the start of its answer. Select Send again to repeat one after fixing a problem.
- If an event has used all its tries and nothing has reached the address in that time, we turn the webhook off and email the organisation owner and the person who added it. Fix the problem, turn it back on and send a test event. Events from while it was off aren't sent.
- Delivery history is kept for 30 days.
Slack and Microsoft Teams
Choose Slack or Microsoft Teams when adding the webhook, and each event is posted to the channel as one short sentence, such as "go.example.com/sale reached 1,000 clicks from people." Nothing needs to be written or hosted.
- Slack: create an app with incoming webhooks for your workspace, add one for the channel, and paste its address. It starts with
https://hooks.slack.com/. - Microsoft Teams: in the channel, add the workflow that posts webhook requests to it, then paste the address the workflow gives you.
Where to find it in Teams
Teams → the channel → ⋯ → Workflows → "Post to a channel when a webhook request is received"
Slack and Teams messages aren't signed: anyone with the address can post to the channel, so keep it private.
Zapier, Make and similar tools
Choose your own system (signed JSON), and paste the address your tool gives you for an incoming webhook trigger, such as Zapier's "Webhooks by Zapier: Catch Hook" or Make's "Custom webhook". Send a test event so the tool learns the fields. The tool can then use type and anything in data in the steps that follow. Most of these tools don't check signatures; that's fine as long as you keep the address private.
Common problems
| What you see | What to do |
|---|---|
| "Webhook addresses must be on the public internet" | We only post to public addresses. To test on your own computer, use a tunnelling tool that gives you a public https address. |
| Every delivery says HTTP 301 or 302 | We don't follow redirects. Use the address the redirect goes to, often with or without www or a trailing slash. |
| Your signature check always fails | Check against the raw body, not re-encoded JSON; use the whole secret including whsec_; and make sure your server's clock is right. |
| No click milestone events | Milestones count people only, are sent once per link, and only when a link passes the number after the webhook was added. |
| The webhook shows "Turned off after failures" | Look at Recent deliveries for the reason, fix it, then select Turn on and send a test event. |
Something missing or unclear? Tell us and we'll improve this page.