Webhooks
Receive signed HTTP requests when issues, projects, or comments change.
Receive HTTP requests when issues, projects, or comments change.
Overview
Pulse webhooks POST a signed JSON envelope to a URL you choose whenever a subscribed issue, project, or comment is created, updated, or removed. Use them to trigger CI, sync another system, or send a message on a condition.
Open Settings → Administration → Webhooks (/settings/webhooks). Creating and managing webhooks requires a workspace manager. If Webhooks is missing from Settings, ask a workspace admin.
v1 delivers Issues, Comments, and Projects. OAuth clients you register for Pulse live under Applications — they are not webhooks.
The number of webhooks is limited by your workspace plan. Disabled webhooks count; deleted ones do not.
How it works
A delivery is an HTTP POST of JSON to your URL. Your endpoint must:
- Be a public
https://URL on port443or8443(not localhost, loopback, or a private address) - Respond with any
2xxstatus within 5 seconds
Pulse does not follow redirects. A 3xx, timeout, network error, or non-2xx status is a failed attempt.
Failed deliveries retry after 1 minute, then 1 hour, then 6 hours. After the fourth attempt the delivery is marked failed. If a webhook has no successful deliveries and at least three failures in 24 hours (test pings excluded), Pulse sets it to Disabled — failing. Fix the receiver, Send test, then Enable webhook.
Getting started
Stand up an HTTPS endpoint that accepts POST, returns 2xx, and verifies the signature (see Securing webhooks). Then create the webhook in Pulse and send a test ping.
Create a webhook
To create a webhook:
- Open
Settings → Administration → Webhooks. - Choose New webhook.
- Set Label (up to 80 characters) and a public URL.
- Select Data change events: Issues, Comments, and/or Projects.
- Under Team selection, choose All teams or Selected teams.
- Choose Create webhook.
Copy the Signing secret immediately and store it with your receiver. You can Show secret later on the webhook, or Rotate secret if you missed it.
Enabled is on by default. While the webhook is disabled, events are not delivered. Test pings still work.
Create a webhook using the API
Workspace managers can also POST /api/v1/webhooks with a bearer token and X-Workspace-ID:
POST https://api.trypulse.tech/api/v1/webhooks
Authorization: Bearer $PULSE_TOKEN
X-Workspace-ID: $WORKSPACE_ID
Content-Type: application/json{
"label": "CI trigger",
"url": "https://example.com/hooks/pulse",
"all_teams": true,
"resource_types": ["Issue", "Comment", "Project"]
}The 201 response includes secret once. List, get, update, rotate, test, and delete use the same /api/v1/webhooks routes.
Send a test
On the webhook, Send test POSTs a signed Ping once, with no retry. It works while the webhook is disabled. A failing receiver is reported in the result, not as an error status.
Delivery headers
Every delivery includes these headers:
| Header | Description |
|---|---|
Content-Type | application/json; charset=utf-8 |
User-Agent | Pulse-Webhook/1 |
Pulse-Delivery | Stable id for this webhook + source event. Same across retries. Dedupe on it. |
Pulse-Event | Issue, Comment, Project, or Ping |
Pulse-Signature | Hex HMAC-SHA256 of the raw body, signed with the webhook secret. No sha256= prefix. |
Pulse-Timestamp | Unix milliseconds of this attempt. Changes on retry. |
Payload
The body is a JSON envelope. data is the serialized subject. webhook_timestamp is stamped per attempt, so the body bytes and signature differ on retry even when Pulse-Delivery does not.
| Field | Description |
|---|---|
action | create, update, or remove. remove carries the last known data. |
type | Issue, Comment, Project, or Ping. Same value as Pulse-Event. |
actor | Who made the change: {id, type, name} with type user, application, or system. null when the user no longer exists or the event has no actor. system has empty id and name. Under agent delegation an Issue or Project names the delegating user, not the agent. |
created_at | When the change happened (UTC, three fractional digits). Identical on every retry. |
data | The subject. Shape depends on type. |
updated_from | Issue and Project update only. Previous value of every data field that changed; null if it was unset. Omitted for comments and for create/remove. |
url | App URL of the item. A comment uses #comment-{id} on its issue or project. Empty when the link could not be built. |
workspace_id | Workspace that owns the webhook. |
webhook_id | This webhook. |
webhook_timestamp | Unix milliseconds of this attempt. Also sent as Pulse-Timestamp. |
Data change events
Subscribe to Issues, Comments, and Projects independently. An update is sent only when an allow-listed field actually changed. updated_at is included in updated_from when something else changed; a write that only moved internal ordering does not deliver.
On remove, data is the last known state and data.updated_at is the time of removal — not the previous edit.
Issues
| Field | Description |
|---|---|
id | Issue id |
code | Identifier such as PUL-13 |
title | Title |
description | Markdown on the issue |
status | backlog, todo, in_progress, qa, release, or done |
priority | no_priority, low, medium, high, or urgent |
type | bug, feature, task, or story |
team_id | Team. null if unset |
project_id | Project. null if unset |
milestone_id | Milestone. null if unset |
cycle_id | Cycle. null if unset |
parent_id | Parent issue. null if unset |
reporter_id | Reporter. null if unset |
assignee_id | Human assignee. null if unset |
delegate_id | Agent the issue is delegated to. The human owner stays in assignee_id. null if unset |
delegation_revision | Increments on every delegate change. 0 if never delegated |
label_ids | Label ids. [] if none |
blocks_ids | Issues this one blocks |
blocked_by_ids | Issues that block this one |
time_estimate | Hours. null if unset |
due_date | Due date. null if unset |
completed_at | Completion time. null if unset |
created_at | Created |
updated_at | Last change. On remove, the removal time |
Example — creating an issue:
{
"action": "create",
"type": "Issue",
"actor": {
"id": "68cab92a5020377746176588",
"type": "user",
"name": "Alireza Attari"
},
"created_at": "2026-05-10T16:06:33.656Z",
"data": {
"id": "6a00ad09f38fa921dd6bd682",
"code": "PUL-13",
"title": "User-configurable outbound webhooks",
"description": "v1",
"status": "in_progress",
"priority": "high",
"type": "feature",
"team_id": "691897efbdac2591ea059cc4",
"project_id": "6a3997a2125a6e096cc47dbe",
"milestone_id": "6a3997a2125a6e096cc47dc0",
"cycle_id": "6a3997a2125a6e096cc47dc1",
"parent_id": "6a3997a2125a6e096cc47dc2",
"reporter_id": "68cab92a5020377746176588",
"assignee_id": "68d91d3629c051fe043158f9",
"delegate_id": "6a00ef0e15d2cc6c31fb7044",
"delegation_revision": 2,
"label_ids": ["6a9be947a14f04b2f099951f"],
"blocks_ids": ["6aab80e4eb36a3827400488d"],
"blocked_by_ids": [],
"time_estimate": 8,
"due_date": "2026-10-01T00:00:00.000Z",
"completed_at": null,
"created_at": "2026-05-10T16:06:33.656Z",
"updated_at": "2026-09-17T10:00:00.000Z"
},
"url": "https://app.trypulse.tech/pulse/issues/6a00ad09f38fa921dd6bd682",
"workspace_id": "6a34cfb024a3b4ed28806e3b",
"webhook_id": "66f1c0a5e4b0a1b2c3d4e5f6",
"webhook_timestamp": 1789646400000
}Projects
| Field | Description |
|---|---|
id | Project id |
title | Title |
description | Description |
status | idea, discovery, proposal, accepted, ready, in_progress, paused, maintenance, completed, or canceled |
priority | no_priority, low, medium, high, or urgent |
health_status | on_track, at_risk, or off_track. null if unset |
owner_id | Owner. null if unset |
lead_id | Lead. null if unset |
member_ids | Member ids |
team_ids | Teams |
initiative_id | Initiative. null if unset |
label_ids | Label ids |
start_date | Start. null if unset |
target_date | Target. null if unset |
completed_at | Completion time. null if unset |
progress | Progress percent. null if unset |
created_at | Created |
updated_at | Last change. On remove, the removal time |
Example — updating a project:
{
"action": "update",
"type": "Project",
"actor": {
"id": "68cab92a5020377746176588",
"type": "user",
"name": "Alireza Attari"
},
"created_at": "2026-09-17T10:00:00.000Z",
"data": {
"id": "6a3997a2125a6e096cc47dbe",
"title": "Integrations",
"description": "Webhooks and friends",
"status": "in_progress",
"priority": "medium",
"health_status": "on_track",
"owner_id": "68cab92a5020377746176588",
"lead_id": null,
"member_ids": ["68d91d3629c051fe043158f9"],
"team_ids": ["691897efbdac2591ea059cc4", "68da1fb8af973c6419b9b3f7"],
"initiative_id": null,
"label_ids": [],
"start_date": "2026-05-10T16:06:33.656Z",
"target_date": "2026-10-01T00:00:00.000Z",
"completed_at": null,
"progress": 42,
"created_at": "2026-05-10T16:06:33.656Z",
"updated_at": "2026-09-17T10:00:00.000Z"
},
"updated_from": {
"status": "ready",
"updated_at": "2026-09-16T22:52:41.899Z"
},
"url": "https://app.trypulse.tech/pulse/projects/6a3997a2125a6e096cc47dbe",
"workspace_id": "6a34cfb024a3b4ed28806e3b",
"webhook_id": "66f1c0a5e4b0a1b2c3d4e5f6",
"webhook_timestamp": 1789646400000
}Comments
Comments on issues and projects only. Internal comments are never delivered.
| Field | Description |
|---|---|
id | Comment id |
text | Body |
author_id | Author. null if unset |
target_type | issue or project |
target_id | Issue or project id |
parent_comment_id | Parent comment for a reply. null on a root comment |
is_resolved | Whether the thread is resolved |
mentioned_user_ids | Mentioned users |
edited_at | Last edit. null if never edited |
created_at | Created |
updated_at | Last change. On remove, the removal time |
Comment events omit updated_from.
Example — creating a comment:
{
"action": "create",
"type": "Comment",
"actor": {
"id": "68cab92a5020377746176588",
"type": "user",
"name": "Alireza Attari"
},
"created_at": "2026-05-10T16:06:33.656Z",
"data": {
"id": "6aab8546eb36a382740048b9",
"text": "Execution split",
"author_id": "68cab92a5020377746176588",
"target_type": "project",
"target_id": "6a3997a2125a6e096cc47dbe",
"parent_comment_id": null,
"is_resolved": true,
"mentioned_user_ids": [],
"edited_at": null,
"created_at": "2026-05-10T16:06:33.656Z",
"updated_at": "2026-05-10T16:06:33.656Z"
},
"url": "https://app.trypulse.tech/pulse/projects/6a3997a2125a6e096cc47dbe#comment-6aab8546eb36a382740048b9",
"workspace_id": "6a34cfb024a3b4ed28806e3b",
"webhook_id": "66f1c0a5e4b0a1b2c3d4e5f6",
"webhook_timestamp": 1789646400000
}Ping
Send test uses action create and type Ping. You cannot subscribe to Ping. It is only sent by Send test.
{
"action": "create",
"type": "Ping",
"actor": {
"id": "68cab92a5020377746176588",
"type": "user",
"name": "Alireza Attari"
},
"created_at": "2026-05-10T16:06:33.656Z",
"data": {
"webhook_id": "66f1c0a5e4b0a1b2c3d4e5f6",
"label": "CI trigger"
},
"url": "",
"workspace_id": "6a34cfb024a3b4ed28806e3b",
"webhook_id": "66f1c0a5e4b0a1b2c3d4e5f6",
"webhook_timestamp": 1789646400000
}Guarantees
Delivery is at least once. Deduplicate with Pulse-Delivery. Events are not ordered — compare data.updated_at and ignore a payload that is not newer than what you already hold.
A webhook scoped to team A still receives the update that moves an issue from A to B (the last event A will see for that issue). An item with no team reaches only All teams webhooks.
Pulse never delivers:
- Bulk imports replaying history
- Personal issues and projects (including the change that made an item personal)
- Internal comments
- Comments on anything other than an issue or a project
- Comments on a personal issue or project
Securing webhooks
Verify every request before you trust it.
- Read the raw body bytes. Do not re-serialize parsed JSON — the signature will not match.
- Compute hex HMAC-SHA256 of those bytes with the signing secret (including the
pwhsec_prefix). - Compare to
Pulse-Signaturewith a timing-safe equal. - Reject the request if
webhook_timestampis more than 60 seconds off your clock.
Do not run a JSON body parser on the route before you capture the raw body. Pulse signs the exact bytes it POSTs.
Rotate secret replaces the secret immediately. Receivers still using the old secret will start rejecting deliveries. Rotate signing secret? cannot be undone.
Node (Express)
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.PULSE_WEBHOOK_SECRET;
function verifySignature(header, rawBody) {
if (typeof header !== "string") return false;
const expected = crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(header, "utf8");
const b = Buffer.from(expected, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post(
"/hooks/pulse",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verifySignature(req.get("pulse-signature"), req.body)) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString("utf8"));
if (Math.abs(Date.now() - payload.webhook_timestamp) > 60_000) {
return res.sendStatus(401);
}
// Handle payload.action / payload.type / payload.data
return res.sendStatus(200);
}
);Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"time"
)
func main() {
secret := os.Getenv("PULSE_WEBHOOK_SECRET")
mux := http.NewServeMux()
mux.HandleFunc("/hooks/pulse", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
rawBody, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(r.Header.Get("Pulse-Signature")), []byte(expected)) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var payload struct {
WebhookTimestamp int64 `json:"webhook_timestamp"`
}
if err := json.Unmarshal(rawBody, &payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
skew := time.Now().UnixMilli() - payload.WebhookTimestamp
if skew < 0 {
skew = -skew
}
if skew > 60_000 {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
})
http.ListenAndServe(":8080", mux)
}Python (Flask)
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
SECRET = os.environ["PULSE_WEBHOOK_SECRET"]
app = Flask(__name__)
def verify(signature: str, raw_body: bytes, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
@app.post("/hooks/pulse")
def pulse_webhook():
raw_body = request.get_data()
signature = request.headers.get("Pulse-Signature", "")
if not verify(signature, raw_body, SECRET):
return ("", 401)
payload = json.loads(raw_body)
if abs(int(time.time() * 1000) - payload["webhook_timestamp"]) > 60_000:
return ("", 401)
return ("", 200)Troubleshooting
Delivery failures on the webhook lists deliveries that exhausted all retries. Open a row for HTTP status and recorded response (bodies are capped).
| Symptom | What to check |
|---|---|
| Signature always fails | Capture raw bytes before any parser. Use the full secret, including pwhsec_. |
timeout | Return 2xx within 5 seconds. Do slow work after you respond. |
blocked_destination / invalid URL | Public https on 443 or 8443. No localhost, private IPs, userinfo, or odd ports. |
HTTP 3xx | Pulse does not follow redirects. Point the URL at the final HTTPS endpoint. |
| Disabled — failing | Fix the receiver, Send test, then Enable webhook. Enabling clears the failing reason. |
Pending deliveries are cancelled when you Delete webhook. That cannot be undone.
FAQ
Last updated on