Integrations: webhooks to chat or your system
Send a project's activity to Slack, Microsoft Teams, Google Chat, Discord or your own system: setup, events, retries, delivery history and signed JSON.
- What can a webhook do?
- How do I add a webhook?
- How do I get a Slack webhook URL?
- How do I get a Microsoft Teams webhook URL?
- How do I get a Google Chat webhook URL?
- How do I get a Discord webhook URL?
- Which events can I send?
- What if a delivery fails?
- What does the JSON payload look like?
- How do I check the signature?
- How are webhooks kept safe?
What can a webhook do?
A webhook posts a project's activity — new issues, status changes, assignments, comments and more — to your team's chat channel or to your own system as it happens. Free on every plan.
- Set up in Project settings → Integrations, by project admins and company admins only (other members do not see the tab)
- Up to 5 webhooks per project, each with its own events and message language (English or Thai)
- Slack, Microsoft Teams, Google Chat and Discord get a ready-made chat message: who did what, the issue with its status, assignee and priority, and a link to open it
- JSON (your system) gets a signed JSON body for your own system to process — see below
- Web app only: AI agents cannot see or change webhooks over MCP
How do I add a webhook?
- Open the board → gear (Project settings) → Integrations → Add webhook
- Choose where to send: Slack, Microsoft Teams, Google Chat, Discord or JSON (your system)
- Give it a name and paste the Webhook URL (how to get it for each service is below)
- Under Send when, tick the events — New issue, Status changed, Assignee changed and New comment are ticked to start with
- Pick the message language and press Save and send test — the test result shows at once
For JSON, the signing secret (whsec_…) is shown once after saving — copy it into your system. New signing secret in the ⋯ menu makes a new one, and the old one stops working at once. Each webhook has an on/off switch, Send test, History, Edit and Delete.
How do I get a Slack webhook URL?
- Open api.slack.com/apps → Create New App → From scratch and pick your workspace (or use a Slack app you already have)
- Incoming Webhooks → switch on Activate Incoming Webhooks → Add New Webhook → choose the channel → Allow
- Copy the Webhook URL (it starts with https://hooks.slack.com/services/) and paste it in BoardMark
How do I get a Microsoft Teams webhook URL?
Teams takes webhooks through the Workflows app (Power Automate); the old Office 365 connectors are being retired. BoardMark sends an Adaptive Card.
- In Teams, open the channel → ⋯ → Workflows
- Choose the template “Post to a channel when a webhook request is received”, name it and sign in if asked
- Check the team and channel, then Add workflow
- Copy the URL Teams shows and paste it in BoardMark
If your organisation restricts Workflows, ask your Microsoft 365 admin.
How do I get a Google Chat webhook URL?
- In Google Chat, open the space → the space name ▾ → Apps & integrations
- Webhooks → Add webhook, give it a name and Save
- Copy the URL (it starts with https://chat.googleapis.com/v1/spaces/) and paste it in BoardMark
Incoming webhooks in Google Chat are for Google Workspace accounts.
How do I get a Discord webhook URL?
- In Discord, open the channel's settings (Edit Channel) → Integrations → Webhooks → New Webhook
- Name it, check the channel, then Copy Webhook URL (it starts with https://discord.com/api/webhooks/)
- Paste it in BoardMark — you need the Manage Webhooks permission on that server
Which events can I send?
| Event | Sent when | Ticked to start |
|---|---|---|
issue.created | New issue | Yes |
issue.status_changed | Status changed — an issue moves to another status, dragging on the board included | Yes |
issue.assigned | Assignee changed | Yes |
comment.added | New comment | Yes |
issue.updated | Other edits — title, priority, labels, due date and other fields (reordering is not sent) | No |
issue.deleted | Issue deleted | No |
sprint.started | Sprint started | No |
sprint.closed | Sprint closed | No |
release.released | Release shipped | No |
One change can be several events: moving an issue and changing its assignee in one save sends both Status changed and Assignee changed. Changes made through an AI agent are sent too.
What if a delivery fails?
- A send counts as delivered when your URL answers 2xx within 10 seconds; redirects are not followed and count as a failure
- A failed send is tried again, up to 8 times over about 1.5 hours
- A failing webhook shows since when it has been failing; still failing after 3 days, it switches itself off and the project admins and company admins get an email. Check the URL, then switch it on again
- History shows every delivery of the last 30 days — time, event, result (status code or error) and attempts; Send again sends one again (the webhook must be on)
- Send test sends a sample Status changed message at any time — nothing changes on the board
What does the JSON payload look like?
A JSON webhook gets a POST with the headers below and a body like the example (texts in the webhook's language). There is never an email address in it — people are names and ids.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | BoardMark-Webhooks/1 |
BoardMark-Event | The event, such as issue.status_changed |
BoardMark-Delivery | whd_… — the same on every retry and Send again; use it to skip duplicates |
BoardMark-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>" with your secret> |
POST /boardmark HTTP/1.1
Content-Type: application/json
User-Agent: BoardMark-Webhooks/1
BoardMark-Event: issue.status_changed
BoardMark-Delivery: whd_01K6Z3P8Q4X7B2M5N6R0S1T2V4
BoardMark-Signature: t=1791183900,v1=5f2c9e…
{
"id": "evt_01K6Z3P8Q4X7B2M5N6R0S1T2V3",
"type": "issue.status_changed",
"occurred_at": "2026-10-05T14:05:00+07:00",
"project": { "key": "APP", "name": "Mobile App" },
"actor": { "id": "usr_01J8Z3K9Q4X7B2M5N6P8R0S1T2", "name": "Fern Suda", "via_agent": false },
"issue": {
"key": "APP-42",
"title": "Checkout shows the wrong price after a coupon",
"type": "bug",
"status": { "from": "in_progress", "to": "in_review" },
"assignee": { "id": "usr_01J8Z3K9Q4X7B2M5N6P8R0S1T2", "name": "Fern Suda" },
"priority": "high",
"url": "https://app.boardmark.ai/p/APP/i/APP-42"
},
"url": "https://app.boardmark.ai/p/APP/i/APP-42"
}- id — the BoardMark event (evt_…); type — the event; test: true only on Send test
- project {key, name} and actor {id, name, via_agent} — via_agent is true when the change came through an AI agent
- issue {key, title, type, status {from, to}, assignee, priority, url} for issue events and comments — status values are the project's status ids (from only on issue.status_changed)
- comment {id, excerpt} (cut at 300 characters), sprint {id, name, state}, release {id, name}, and changed_fields for issue.updated (such as priority, labels)
- url — where to look in the web app
- New keys may be added later: ignore keys you do not know
How do I check the signature?
Compute HMAC-SHA256 over t, a dot and the raw body (exactly the bytes received, before parsing the JSON), with the whole secret including whsec_ as the key, and compare it with v1 in constant time. Refuse a t older than 5 minutes, answer 2xx quickly and do slow work afterwards, and skip a BoardMark-Delivery you have already handled.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const SECRET = process.env.BOARDMARK_WEBHOOK_SECRET; // the whole whsec_… value
// header = BoardMark-Signature, body = the raw request body (Buffer)
export function verify(header, body) {
const f = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const age = Math.abs(Date.now() / 1000 - Number(f.t));
if (!/^\d+$/.test(f.t ?? "") || age > 300) return false;
const want = createHmac("sha256", SECRET).update(`${f.t}.`).update(body).digest();
const got = Buffer.from(f.v1 ?? "", "hex");
return got.length === want.length && timingSafeEqual(got, want);
}
createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = Buffer.concat(chunks);
if (!verify(req.headers["boardmark-signature"] ?? "", body)) return res.writeHead(401).end();
res.writeHead(204).end(); // answer first, then work
const event = JSON.parse(body.toString("utf8"));
// skip a BoardMark-Delivery id you have already handled (retries reuse it)
console.log(req.headers["boardmark-delivery"], event.type, event.issue?.key);
});
}).listen(8080);import hashlib, hmac, os, time
SECRET = os.environ["BOARDMARK_WEBHOOK_SECRET"].encode() # the whole whsec_… value
def verify(header: str, body: bytes) -> bool:
"""header = BoardMark-Signature, body = the raw request body."""
f = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t = f.get("t", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
want = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, f.get("v1", ""))
# Flask:
# @app.post("/boardmark")
# def boardmark():
# if not verify(request.headers.get("BoardMark-Signature", ""), request.get_data()):
# abort(401)
# event = request.get_json()
# # skip a BoardMark-Delivery id you have already handled (retries reuse it)
# return "", 204package boardmark
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// Verify checks the BoardMark-Signature header against the raw body.
// secret is the whole whsec_… value.
func Verify(secret, header string, body []byte) bool {
var t, v1 string
for _, p := range strings.Split(header, ",") {
k, v, _ := strings.Cut(p, "=")
switch k {
case "t":
t = v
case "v1":
v1 = v
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil {
return false
}
if age := time.Now().Unix() - ts; age > 300 || age < -300 {
return false
}
got, err := hex.DecodeString(v1)
if err != nil {
return false
}
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(t + "."))
m.Write(body)
return hmac.Equal(got, m.Sum(nil))
}
func Handler(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || !Verify(secret, r.Header.Get("BoardMark-Signature"), body) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// skip a BoardMark-Delivery id you have already handled (retries reuse it)
w.WriteHeader(http.StatusNoContent)
}
}How are webhooks kept safe?
- The URL must be https:// on the standard port and point to a public address — internal IPs, localhost and private networks are refused, when you save and again on every send
- Redirects are not followed, and each send waits at most 10 seconds
- The URL and secret are stored encrypted; after saving, the page shows only the host and the end of the URL
- A chat webhook URL works like a password — anyone who has it can post to your channel. If it leaks, make a new one in the chat app and Edit the webhook in BoardMark
- Payloads carry names and ids, never email addresses, and comment text is cut at 300 characters
- Only project admins and company admins can see or change webhooks; AI agents cannot, over MCP