BoardMark
Docs

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?

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?

EventSent whenTicked to start
issue.createdNew issueYes
issue.status_changedStatus changed — an issue moves to another status, dragging on the board includedYes
issue.assignedAssignee changedYes
comment.addedNew commentYes
issue.updatedOther edits — title, priority, labels, due date and other fields (reordering is not sent)No
issue.deletedIssue deletedNo
sprint.startedSprint startedNo
sprint.closedSprint closedNo
release.releasedRelease shippedNo

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.

HeaderValue
Content-Typeapplication/json
User-AgentBoardMark-Webhooks/1
BoardMark-EventThe event, such as issue.status_changed
BoardMark-Deliverywhd_… — the same on every retry and Send again; use it to skip duplicates
BoardMark-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>" with your secret>
Example: status changed
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.

Node.js (no packages needed)
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);
Python (standard library)
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 "", 204
Go (standard library)
package 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