وب‌هوک‌ها

وقتی Issue، پروژه یا Comment تغییر می‌کند، درخواست HTTP امضاشده دریافت کنید.

وقتی Issue، پروژه یا Comment تغییر می‌کند درخواست HTTP دریافت کنید.

مرور

وب‌هوک Pulse یک پاکت JSON امضاشده را به URLی که انتخاب می‌کنید POST می‌کند؛ هر بار که Issue، پروژه یا Comment مشترک‌شده ساخته، به‌روز یا حذف شود. برای تریگر CI، همگام‌سازی سیستم دیگر، یا ارسال پیام روی یک شرط از آن استفاده کنید.

Settings → Administration → Webhooks (/settings/webhooks) را باز کنید. ساخت و مدیریت وب‌هوک فقط برای مدیر فضای کاری است. اگر Webhooks در Settings نیست، از ادمین فضای کاری بخواهید.

v1 رویدادهای Issues، Comments و Projects را می‌فرستد. کلاینت OAuth که برای Pulse ثبت می‌کنید در برنامه‌ها است — وب‌هوک نیست.

تعداد وب‌هوک‌ها با پلن فضای کاری محدود است. وب‌هوک غیرفعال شمرده می‌شود؛ حذف‌شده شمرده نمی‌شود.

چگونه کار می‌کند

هر تحویل یک HTTP POST از JSON به URL شماست. endpoint باید:

  • یک URL عمومی https:// روی پورت 443 یا 8443 باشد (نه localhost، loopback یا آدرس خصوصی)
  • ظرف ۵ ثانیه هر وضعیت 2xx برگرداند

Pulse ریدایرکت را دنبال نمی‌کند. 3xx، timeout، خطای شبکه یا وضعیت غیر 2xx یک تلاش ناموفق است.

تحویل ناموفق بعد از ۱ دقیقه، سپس ۱ ساعت، سپس ۶ ساعت دوباره تلاش می‌شود. بعد از تلاش چهارم، تحویل failed می‌شود. اگر وب‌هوکی در ۲۴ ساعت هیچ تحویل موفقی نداشته باشد و حداقل سه شکست داشته باشد (پینگ تست به‌حساب نمی‌آید)، Pulse آن را Disabled — failing می‌کند. گیرنده را درست کنید، Send test بزنید، سپس Enable webhook.

شروع کار

یک endpoint HTTPS بسازید که POST بپذیرد، 2xx برگرداند و امضا را بررسی کند (ببینید امن‌سازی وب‌هوک‌ها). بعد وب‌هوک را در Pulse بسازید و یک پینگ تست بفرستید.

ساخت وب‌هوک

برای ساخت وب‌هوک:

  1. Settings → Administration → Webhooks را باز کنید.
  2. New webhook را بزنید.
  3. Label (تا ۸۰ نویسه) و یک URL عمومی بگذارید.
  4. Data change events را انتخاب کنید: Issues، Comments و/یا Projects.
  5. در Team selection، All teams یا Selected teams.
  6. Create webhook را بزنید.

Signing secret را همان لحظه کپی کنید و کنار گیرنده نگه دارید. بعداً روی وب‌هوک می‌توانید Show secret کنید، یا اگر از دست رفت Rotate secret.

Enabled به‌طور پیش‌فرض روشن است. وقتی وب‌هوک غیرفعال است رویدادها تحویل نمی‌شوند. پینگ تست همچنان کار می‌کند.

ساخت وب‌هوک با API

مدیران فضای کاری می‌توانند با bearer token و X-Workspace-ID به POST /api/v1/webhooks بزنند:

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"]
}

پاسخ 201 مقدار secret را یک‌بار دارد. فهرست، دریافت، به‌روزرسانی، چرخش، تست و حذف همان مسیرهای /api/v1/webhooks هستند.

ارسال تست

روی وب‌هوک، Send test یک Ping امضاشده را یک‌بار POST می‌کند، بدون retry. وقتی وب‌هوک غیرفعال است هم کار می‌کند. گیرندهٔ ناموفق در نتیجه گزارش می‌شود، نه به‌عنوان وضعیت خطا.

هدرهای تحویل

هر تحویل این هدرها را دارد:

هدرتوضیح
Content-Typeapplication/json; charset=utf-8
User-AgentPulse-Webhook/1
Pulse-Deliveryشناسه پایدار برای این وب‌هوک + رویداد مبدأ. در retry همان است. با آن تکراری را حذف کنید.
Pulse-EventIssue، Comment، Project یا Ping
Pulse-SignatureHMAC-SHA256 هگز از بدنهٔ خام، با secret وب‌هوک. پیشوند sha256= ندارد.
Pulse-Timestampمیلی‌ثانیهٔ Unix این تلاش. در retry عوض می‌شود.

محموله

بدنه یک پاکت JSON است. data موضوع سریال‌شده است. webhook_timestamp در هر تلاش زده می‌شود؛ بنابراین بایت‌های بدنه و امضا در retry فرق می‌کنند، حتی اگر Pulse-Delivery همان باشد.

فیلدتوضیح
actioncreate، update یا remove. remove آخرین data شناخته‌شده را می‌آورد.
typeIssue، Comment، Project یا Ping. همان مقدار Pulse-Event.
actorچه کسی تغییر را زد: {id, type, name} با typeی user، application یا system. اگر کاربر دیگر وجود نداشته باشد یا رویداد actor نداشته باشد null. system مقدار id و name خالی دارد. در تفویض به Agent، رویداد Issue یا Project نام کاربر تفویض‌کننده را می‌گذارد، نه Agent.
created_atزمان تغییر (UTC، سه رقم کسری). در هر retry یکسان است.
dataموضوع. شکل آن به type بستگی دارد.
updated_fromفقط update برای Issue و Project. مقدار قبلی هر فیلد data که عوض شده؛ اگر قبلاً خالی بوده null. برای Comment و برای create/remove نیست.
urlآدرس آیتم در اپ. Comment با #comment-{id} روی Issue یا پروژه لینک می‌شود. اگر لینک ساخته نشود خالی است.
workspace_idفضای کاری مالک وب‌هوک.
webhook_idهمین وب‌هوک.
webhook_timestampمیلی‌ثانیهٔ Unix این تلاش. همان Pulse-Timestamp.

رویدادهای تغییر داده

Issues، Comments و Projects را جداگانه مشترک شوید. update فقط وقتی فرستاده می‌شود که فیلدی در فهرست مجاز واقعاً عوض شده باشد. updated_at وقتی چیز دیگری عوض شده در updated_from می‌آید؛ نوشته‌ای که فقط ترتیب داخلی را جابه‌جا کند تحویل نمی‌شود.

در remove، data آخرین وضعیت شناخته‌شده است و data.updated_at زمان حذف است — نه ویرایش قبلی.

Issueها

فیلدتوضیح
idشناسهٔ Issue
codeشناسه مثل PUL-13
titleعنوان
descriptionMarkdown روی Issue
statusbacklog، todo، in_progress، qa، release یا done
priorityno_priority، low، medium، high یا urgent
typebug، feature، task یا story
team_idتیم. اگر نباشد null
project_idپروژه. اگر نباشد null
milestone_idمایل‌استون. اگر نباشد null
cycle_idسیکل. اگر نباشد null
parent_idIssue والد. اگر نباشد null
reporter_idگزارش‌دهنده. اگر نباشد null
assignee_idمسئول انسانی. اگر نباشد null
delegate_idAgentی که Issue به آن تفویض شده. مالک انسانی در assignee_id می‌ماند. اگر نباشد null
delegation_revisionبا هر تغییر delegate زیاد می‌شود. اگر هرگز تفویض نشده 0
label_idsشناسهٔ برچسب‌ها. اگر هیچ‌کدام نباشد []
blocks_idsIssueهایی که این یکی مسدودشان می‌کند
blocked_by_idsIssueهایی که این یکی را مسدود می‌کنند
time_estimateساعت. اگر نباشد null
due_dateموعد. اگر نباشد null
completed_atزمان اتمام. اگر نباشد null
created_atزمان ساخت
updated_atآخرین تغییر. در remove زمان حذف

نمونه — ساخت یک 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
}

پروژه‌ها

فیلدتوضیح
idشناسهٔ پروژه
titleعنوان
descriptionتوضیح
statusidea، discovery، proposal، accepted، ready، in_progress، paused، maintenance، completed یا canceled
priorityno_priority، low، medium، high یا urgent
health_statuson_track، at_risk یا off_track. اگر نباشد null
owner_idمالک. اگر نباشد null
lead_idلید. اگر نباشد null
member_idsشناسهٔ اعضا
team_idsتیم‌ها
initiative_idInitiative. اگر نباشد null
label_idsشناسهٔ برچسب‌ها
start_dateشروع. اگر نباشد null
target_dateهدف. اگر نباشد null
completed_atزمان اتمام. اگر نباشد null
progressدرصد پیشرفت. اگر نباشد null
created_atزمان ساخت
updated_atآخرین تغییر. در remove زمان حذف

نمونه — به‌روزرسانی یک پروژه:

{
  "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
}

Commentها

فقط Comment روی Issue و پروژه. Comment داخلی هرگز تحویل نمی‌شود.

فیلدتوضیح
idشناسهٔ Comment
textمتن
author_idنویسنده. اگر نباشد null
target_typeissue یا project
target_idشناسهٔ Issue یا پروژه
parent_comment_idComment والد برای پاسخ. در ریشه null
is_resolvedآیا نخ حل شده
mentioned_user_idsکاربران منشن‌شده
edited_atآخرین ویرایش. اگر هرگز ویرایش نشده null
created_atزمان ساخت
updated_atآخرین تغییر. در remove زمان حذف

رویداد Comment فیلد updated_from ندارد.

نمونه — ساخت یک 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 از actionی create و typeی Ping استفاده می‌کند. نمی‌توانید به Ping مشترک شوید. فقط 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
}

تضمین‌ها

تحویل حداقل یک‌بار است. تکراری را با Pulse-Delivery حذف کنید. رویدادها مرتب نیستند — data.updated_at را مقایسه کنید و محموله‌ای را که از آنچه دارید جدیدتر نیست نادیده بگیرید.

وب‌هوکی که به تیم A محدود است همان به‌روزرسانی را که Issue را از A به B می‌برد هم می‌گیرد (آخرین رویدادی که A برای آن Issue می‌بیند). آیتم بدون تیم فقط به وب‌هوک‌های All teams می‌رسد.

Pulse هرگز این‌ها را تحویل نمی‌دهد:

  • درون‌ریزی گروهی که تاریخچه را بازپخش می‌کند
  • Issue و پروژهٔ شخصی (از جمله تغییری که آیتم را شخصی کرد)
  • Comment داخلی
  • Comment روی چیزی غیر از Issue یا پروژه
  • Comment روی Issue یا پروژهٔ شخصی

امن‌سازی وب‌هوک‌ها

قبل از اعتماد به هر درخواست آن را بررسی کنید.

  1. بایت‌های خام بدنه را بخوانید. JSON پارس‌شده را دوباره سریال نکنید — امضا جور درنمی‌آید.
  2. HMAC-SHA256 هگز همان بایت‌ها را با signing secret (از جمله پیشوند pwhsec_) حساب کنید.
  3. با Pulse-Signature به‌صورت timing-safe مقایسه کنید.
  4. اگر webhook_timestamp بیش از ۶۰ ثانیه با ساعت شما فاصله دارد درخواست را رد کنید.

قبل از گرفتن بدنهٔ خام، JSON body parser روی این مسیر اجرا نکنید. Pulse دقیقاً همان بایت‌هایی را که POST می‌کند امضا می‌کند.

Rotate secret فوراً secret را عوض می‌کند. گیرنده‌هایی که هنوز secret قدیمی را دارند تحویل را رد می‌کنند. Rotate signing secret? برگشت‌پذیر نیست.

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)

عیب‌یابی

Delivery failures روی وب‌هوک تحویل‌هایی را نشان می‌دهد که همهٔ retryها را تمام کرده‌اند. یک ردیف را برای وضعیت HTTP و پاسخ ثبت‌شده باز کنید (بدنه‌ها سقف دارند).

نشانهچه را چک کنید
امضا همیشه fail می‌شودبایت خام را قبل از هر parser بگیرید. کل secret را با pwhsec_ استفاده کنید.
timeoutظرف ۵ ثانیه 2xx برگردانید. کار کند را بعد از پاسخ انجام دهید.
blocked_destination / URL نامعتبرhttps عمومی روی 443 یا 8443. نه localhost، IP خصوصی، userinfo یا پورت غیرمجاز.
HTTP 3xxPulse ریدایرکت را دنبال نمی‌کند. URL را به endpoint نهایی HTTPS بدهید.
Disabled — failingگیرنده را درست کنید، Send test، سپس Enable webhook. فعال‌کردن دلیل failing را پاک می‌کند.

وقتی Delete webhook می‌زنید تحویل‌های pending لغو می‌شوند. این کار برگشت‌پذیر نیست.

پرسش‌های متداول

آخرین به‌روزرسانی