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 port 443 or 8443 (not localhost, loopback, or a private address)
  • Respond with any 2xx status 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:

  1. Open Settings → Administration → Webhooks.
  2. Choose New webhook.
  3. Set Label (up to 80 characters) and a public URL.
  4. Select Data change events: Issues, Comments, and/or Projects.
  5. Under Team selection, choose All teams or Selected teams.
  6. 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:

HeaderDescription
Content-Typeapplication/json; charset=utf-8
User-AgentPulse-Webhook/1
Pulse-DeliveryStable id for this webhook + source event. Same across retries. Dedupe on it.
Pulse-EventIssue, Comment, Project, or Ping
Pulse-SignatureHex HMAC-SHA256 of the raw body, signed with the webhook secret. No sha256= prefix.
Pulse-TimestampUnix 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.

FieldDescription
actioncreate, update, or remove. remove carries the last known data.
typeIssue, Comment, Project, or Ping. Same value as Pulse-Event.
actorWho 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_atWhen the change happened (UTC, three fractional digits). Identical on every retry.
dataThe subject. Shape depends on type.
updated_fromIssue and Project update only. Previous value of every data field that changed; null if it was unset. Omitted for comments and for create/remove.
urlApp URL of the item. A comment uses #comment-{id} on its issue or project. Empty when the link could not be built.
workspace_idWorkspace that owns the webhook.
webhook_idThis webhook.
webhook_timestampUnix 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

FieldDescription
idIssue id
codeIdentifier such as PUL-13
titleTitle
descriptionMarkdown on the issue
statusbacklog, todo, in_progress, qa, release, or done
priorityno_priority, low, medium, high, or urgent
typebug, feature, task, or story
team_idTeam. null if unset
project_idProject. null if unset
milestone_idMilestone. null if unset
cycle_idCycle. null if unset
parent_idParent issue. null if unset
reporter_idReporter. null if unset
assignee_idHuman assignee. null if unset
delegate_idAgent the issue is delegated to. The human owner stays in assignee_id. null if unset
delegation_revisionIncrements on every delegate change. 0 if never delegated
label_idsLabel ids. [] if none
blocks_idsIssues this one blocks
blocked_by_idsIssues that block this one
time_estimateHours. null if unset
due_dateDue date. null if unset
completed_atCompletion time. null if unset
created_atCreated
updated_atLast 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

FieldDescription
idProject id
titleTitle
descriptionDescription
statusidea, discovery, proposal, accepted, ready, in_progress, paused, maintenance, completed, or canceled
priorityno_priority, low, medium, high, or urgent
health_statuson_track, at_risk, or off_track. null if unset
owner_idOwner. null if unset
lead_idLead. null if unset
member_idsMember ids
team_idsTeams
initiative_idInitiative. null if unset
label_idsLabel ids
start_dateStart. null if unset
target_dateTarget. null if unset
completed_atCompletion time. null if unset
progressProgress percent. null if unset
created_atCreated
updated_atLast 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.

FieldDescription
idComment id
textBody
author_idAuthor. null if unset
target_typeissue or project
target_idIssue or project id
parent_comment_idParent comment for a reply. null on a root comment
is_resolvedWhether the thread is resolved
mentioned_user_idsMentioned users
edited_atLast edit. null if never edited
created_atCreated
updated_atLast 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.

  1. Read the raw body bytes. Do not re-serialize parsed JSON — the signature will not match.
  2. Compute hex HMAC-SHA256 of those bytes with the signing secret (including the pwhsec_ prefix).
  3. Compare to Pulse-Signature with a timing-safe equal.
  4. Reject the request if webhook_timestamp is 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).

SymptomWhat to check
Signature always failsCapture raw bytes before any parser. Use the full secret, including pwhsec_.
timeoutReturn 2xx within 5 seconds. Do slow work after you respond.
blocked_destination / invalid URLPublic https on 443 or 8443. No localhost, private IPs, userinfo, or odd ports.
HTTP 3xxPulse does not follow redirects. Point the URL at the final HTTPS endpoint.
Disabled — failingFix 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