Skip to content

Webhooks

Webhooks let you subscribe to TruePPM project events and receive an HTTP POST to a URL you control when those events occur. Common uses: posting notifications to Slack, triggering a CI pipeline when a milestone is resolved, or syncing changes to an external system.

Register a webhook from the Integrations page (Project → Settings → Integrations or Program → Settings → Integrations) or via the API:

POST /api/v1/projects/{project_id}/webhooks/
Content-Type: application/json
{
"url": "https://hooks.example.com/trueppm",
"secret": "your-shared-secret",
"events": ["task.created", "task.updated", "schedule.recalculated"],
"format": "generic"
}

events is an array of one or more event type strings (see below). Omit events to subscribe to all event types. format selects how the payload is rendered — generic (default) or slack (see Payload format).

Permissions: requires Admin role on the project (or program, for program-scoped webhooks).

A webhook is scoped to exactly one project or one program:

  • Project/api/v1/projects/{id}/webhooks/ — fires for events on that one project.
  • Program/api/v1/programs/{id}/webhooks/ — fires for events on any project in the program. Configure one endpoint once instead of copying it into every child project.

Program-scoped reads require program Viewer+; mutations require program Admin. The two scopes are additive: a project event reaches both its own project webhooks and its program’s webhooks.

Each webhook renders its payload in one of two OSS formats, set per subscription via the format field:

FormatWhat is sent
generic (default)The raw TruePPM event envelope, unchanged (see Payload shape).
slackA Slack incoming-webhook message (text + a single attachment). Discord and Mattermost incoming webhooks accept the same shape, so one format covers all three.

Point a slack-format webhook at a Slack/Discord/Mattermost incoming-webhook URL and messages render in-channel with no consumer-side parsing. Richer formats (Slack App, Teams, PagerDuty) are an Enterprise feature and register against the same extension point without an OSS change.

OSS fires 14 event types (a deliberate hard cap):

EventWhen fired
task.createdA task is created
task.updatedA task field is changed
task.deletedA task is deleted
task.assignedA task’s assignee transitions from nobody to a user
task.assignee_changedA task is reassigned from one user to another
task.mentionedA new comment mentions a user
task.due_date_changedA task’s planned date changes (see note)
dependency.createdA task link (FS/SS/FF/SF) is created
dependency.deletedA task link is deleted
schedule.recalculatedThe CPM scheduler completes a recalculation
project.createdA new project is created in the organization
sprint.activatedA sprint transitions PLANNED → ACTIVE
sprint.closedA sprint is closed (carries the completion snapshot — see note)
sprint.scope_changedA mid-sprint scope injection is accepted into the commitment

The last four task events were added in 0.2 (available since the 0.2.0-alpha.1 pre-release). A single PATCH that both reassigns a task and moves its date fires task.updated plus the specific events — subscribe to whichever you want.

The three sprint.* events were added in 0.3 so external dashboards, Slack, and CI can observe the sprint cadence. sprint.scope_changed fires only when a mid-sprint injection is accepted (it models scope that entered the commitment) — never on a silent injection or a reject.

A generic-format delivery sends the flat event payload — for task events, the changed task’s fields at the top level — plus a reserved _meta object:

{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"project": "9c8b...",
"name": "Draft proposal",
"status": "in_progress",
"duration": 5,
"assignee": "7a1f...",
"planned_start": "2026-05-11",
"actual_start": null,
"actual_finish": null,
"source": "schedule",
"_meta": { "sequence": 42 }
}

The domain fields are the task as serialized for the event; the event type itself is carried in the X-TruePPM-Event header, not the body. (slack-format deliveries instead send a Slack message — { "text", "attachments" } — with the same _meta object added.)

_meta is a reserved top-level namespace for delivery metadata, kept separate from the domain fields so it can never collide with a payload field of the same name. Today it holds one key: _meta.sequence, the per-subscription delivery sequence number (see Delivery ordering and gap detection). It is added to every format, so a consumer can detect gaps from the body alone without reading the X-TruePPM-Webhook-Sequence header — the two always carry the same value.

Every request includes an X-TruePPM-Signature header:

X-TruePPM-Signature: sha256=<hmac>

The HMAC is HMAC-SHA256(secret, raw_body) where secret is the value you supplied at registration and raw_body is the raw request bytes.

Example verification in Python:

import hashlib, hmac
def verify(secret: str, body: bytes, signature: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)

Always use a constant-time comparison to prevent timing attacks.

Every delivery carries these headers. Delivery metadata lives in headers; the only metadata also mirrored into the body is _meta.sequence, so that in-body gap detection does not require reading headers.

HeaderValue
X-TruePPM-EventThe event type (e.g. task.updated)
X-TruePPM-DeliveryUUID of this delivery record
X-TruePPM-Signaturesha256=<hmac> (see above)
X-TruePPM-Webhook-SequenceMonotonic per-subscription sequence number (see below)

Deliveries are at-least-once and their arrival order at your endpoint is not guaranteed — two events that race (e.g. task.updated then task.deleted) can arrive in either order. To let you cope with this, every delivery carries a sequence number — both in the X-TruePPM-Webhook-Sequence header and as _meta.sequence in the body (see Payload shape):

  • The number is monotonic and contiguous per subscription: the first delivery to a given webhook is 1, the next 2, and so on. It is not shared across webhooks — each registration has its own counter.
  • It is stable across retries: a redelivered event keeps the same number.
  • It survives delivery-history pruning — a number is never reused, even after old WebhookDelivery records are purged.

Consumers MAY use the sequence to:

  • Detect gaps — if you receive sequence 7 then 9, delivery 8 is missing (lost or still in flight). You can inspect it via the delivery history endpoint.
  • Reorder events that arrive out of order by buffering on the sequence.

The sequence is a hint, not a contract: TruePPM still guarantees only eventual, at-least-once delivery — not strict ordering or exactly-once. Use the sequence alongside idempotent handling keyed on X-TruePPM-Delivery.

The same value is carried in three places, always identical: the X-TruePPM-Webhook-Sequence header, _meta.sequence in the delivered body, and sequence_number on each record from the delivery history endpoint.

TruePPM retries failed deliveries (non-2xx response or connection error) up to 5 times with exponential back-off (30s, 60s, 120s, 240s, 480s). After the final failure the delivery record is marked failed.

GET /api/v1/projects/{project_id}/webhooks/{webhook_id}/deliveries/
GET /api/v1/programs/{program_id}/webhooks/{webhook_id}/deliveries/

Returns paginated WebhookDelivery records with sequence_number, status, response_status, attempt_count, and timestamps. Useful for debugging and for inspecting a delivery flagged as a gap by its sequence number.

Set is_active: false via PATCH to pause deliveries without deleting the registration:

PATCH /api/v1/projects/{project_id}/webhooks/{webhook_id}/
{"is_active": false}
ActionMinimum role
List / view webhooksViewer
Create / update / delete webhooksAdmin

The same roles apply at each scope: project Viewer/Admin for project-scoped webhooks, program Viewer/Admin for program-scoped ones.