وبهوکها
وقتی 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 بسازید و یک پینگ تست بفرستید.
ساخت وبهوک
برای ساخت وبهوک:
Settings → Administration → Webhooksرا باز کنید.- New webhook را بزنید.
- Label (تا ۸۰ نویسه) و یک URL عمومی بگذارید.
- Data change events را انتخاب کنید: Issues، Comments و/یا Projects.
- در Team selection، All teams یا Selected teams.
- 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-Type | application/json; charset=utf-8 |
User-Agent | Pulse-Webhook/1 |
Pulse-Delivery | شناسه پایدار برای این وبهوک + رویداد مبدأ. در retry همان است. با آن تکراری را حذف کنید. |
Pulse-Event | Issue، Comment، Project یا Ping |
Pulse-Signature | HMAC-SHA256 هگز از بدنهٔ خام، با secret وبهوک. پیشوند sha256= ندارد. |
Pulse-Timestamp | میلیثانیهٔ Unix این تلاش. در retry عوض میشود. |
محموله
بدنه یک پاکت JSON است. data موضوع سریالشده است. webhook_timestamp در هر تلاش زده میشود؛ بنابراین بایتهای بدنه و امضا در retry فرق میکنند، حتی اگر Pulse-Delivery همان باشد.
| فیلد | توضیح |
|---|---|
action | create، update یا remove. remove آخرین data شناختهشده را میآورد. |
type | Issue، 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 | عنوان |
description | Markdown روی Issue |
status | backlog، todo، in_progress، qa، release یا done |
priority | no_priority، low، medium، high یا urgent |
type | bug، feature، task یا story |
team_id | تیم. اگر نباشد null |
project_id | پروژه. اگر نباشد null |
milestone_id | مایلاستون. اگر نباشد null |
cycle_id | سیکل. اگر نباشد null |
parent_id | Issue والد. اگر نباشد null |
reporter_id | گزارشدهنده. اگر نباشد null |
assignee_id | مسئول انسانی. اگر نباشد null |
delegate_id | Agentی که Issue به آن تفویض شده. مالک انسانی در assignee_id میماند. اگر نباشد null |
delegation_revision | با هر تغییر delegate زیاد میشود. اگر هرگز تفویض نشده 0 |
label_ids | شناسهٔ برچسبها. اگر هیچکدام نباشد [] |
blocks_ids | Issueهایی که این یکی مسدودشان میکند |
blocked_by_ids | Issueهایی که این یکی را مسدود میکنند |
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 | توضیح |
status | idea، discovery، proposal، accepted، ready، in_progress، paused، maintenance، completed یا canceled |
priority | no_priority، low، medium، high یا urgent |
health_status | on_track، at_risk یا off_track. اگر نباشد null |
owner_id | مالک. اگر نباشد null |
lead_id | لید. اگر نباشد null |
member_ids | شناسهٔ اعضا |
team_ids | تیمها |
initiative_id | Initiative. اگر نباشد 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_type | issue یا project |
target_id | شناسهٔ Issue یا پروژه |
parent_comment_id | Comment والد برای پاسخ. در ریشه 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 یا پروژهٔ شخصی
امنسازی وبهوکها
قبل از اعتماد به هر درخواست آن را بررسی کنید.
- بایتهای خام بدنه را بخوانید. JSON پارسشده را دوباره سریال نکنید — امضا جور درنمیآید.
- HMAC-SHA256 هگز همان بایتها را با signing secret (از جمله پیشوند
pwhsec_) حساب کنید. - با
Pulse-Signatureبهصورت timing-safe مقایسه کنید. - اگر
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 3xx | Pulse ریدایرکت را دنبال نمیکند. URL را به endpoint نهایی HTTPS بدهید. |
| Disabled — failing | گیرنده را درست کنید، Send test، سپس Enable webhook. فعالکردن دلیل failing را پاک میکند. |
وقتی Delete webhook میزنید تحویلهای pending لغو میشوند. این کار برگشتپذیر نیست.
پرسشهای متداول
آخرین بهروزرسانی