Webhook Integration
Webhooks let Align push events to a URL you own, so you do not have to poll. When an event you subscribed to happens in an organisation, Align queues a signed HTTP POST to your endpoint and sends it shortly afterwards.
Subscriptions belong to one organisation. An event is delivered only to subscriptions in the same organisation as the event.
Outbound events must be on
Subscriptions hang off an integration connection in the organisation, and Align delivers only when that connection has outbound events turned on.
- If the organisation has no TaskFlow connection, the first subscription created through the API creates a hidden connection for it with outbound events on. Nothing more is needed.
- If the organisation already has a TaskFlow connection, new subscriptions use it, and its switch starts off. An admin turns it on in Settings → Integrations → Taskflow → Configure, under Outbound Webhook Subscriptions, with Outbound events delivery. While it is off, events are recorded but nothing is sent, and test deliveries send nothing either.
Creating a subscription
Through the API
You need an organisation-scoped key with write:webhook.
curl https://app.alignsoft.us/api/v1/webhooks \
-H "Authorization: Bearer $ALIGN_KEY" \
-H "Content-Type: application/json" \
-d '{
"destinationUrl": "https://yourapp.example.com/hooks/align",
"eventNames": ["entry.created", "entry.status_changed", "release.released"],
"description": "Production sync"
}'
destinationUrlmust be anhttps://URL on a public host. Otherwise the request returns400 validation_error. See Where deliveries can go.eventNamesmust be a non-empty array of names from the event vocabulary. An unknown name returns400with{ "error": "unknown_event_names", "invalid": ["..."] }.descriptionis optional.
The 201 response includes hmacSecret (it starts with whsec_). Save it now. No other API response returns it, and the API can't retrieve or rotate it. When the organisation has a TaskFlow connection, an admin can rotate it in Align: in Outbound Webhook Subscriptions (under the Taskflow configuration), click the Rotate HMAC secret button on the subscription's row. Otherwise, to change the secret, create a new subscription and delete the old one.
In Align
Admins can manage subscriptions in Settings → Developer → Webhooks:
- Click Add subscription.
- Enter the Destination URL and select the events. In Align you can also choose the
*wildcard, which subscribes to every event; the API does not accept*. - Click Create and copy the signing secret from the Copy your webhook signing secret dialog.
Creating a subscription in Align needs an integration connection to exist already. Without one, Align refuses with "No Taskflow connection found for your organization". Creating the first subscription through the API sets one up.
Managing subscriptions
| Action | Request | Permission |
|---|---|---|
| List | GET /api/v1/webhooks | read:webhook |
| Get one | GET /api/v1/webhooks/{id} | read:webhook |
Update destinationUrl, eventNames, description or enabled | PATCH /api/v1/webhooks/{id} | write:webhook |
| Delete | DELETE /api/v1/webhooks/{id} | delete:webhook |
| Send a test delivery | POST /api/v1/webhooks/{id}/test | write:webhook |
Set enabled to false to pause deliveries without deleting the subscription. All webhook endpoints need an organisation-scoped key.
A test delivery needs write:webhook because it makes Align send a request and records a delivery. Until 2026-11-09 it needed only read:webhook; a key that tests the subscriptions it creates already holds write:webhook.
Where deliveries can go
A destinationUrl must be an https:// URL on a public host:
- A literal address that is loopback, private, link-local, carrier-grade NAT, NAT64, multicast or otherwise never public is refused, however it is written (including the
[::ffff:…]IPv6 forms of an IPv4 address). So are the nameslocalhostand*.internal. - A host name is resolved when you save it, and refused if any address it resolves to is one of those. A name that does not resolve yet is accepted; it is checked again on delivery.
- Every delivery is checked again when it is sent, against the address the name resolves to at that moment. A subscription that no longer passes is not sent anything: each delivery is dead-lettered with the reason "Destination not allowed", and is not retried. Edit the
destinationUrlto resume. - Redirects are not followed. A 3xx response is a failed delivery.
Align does not keep your endpoint's response body, and no API returns it. A delivery record holds the status code, or Align's own error message when there was no response.
Delivery format
Each delivery is a POST with this JSON body:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"eventName": "entry.created",
"schemaVersion": 1,
"entityType": "entry",
"entityId": "entry-uuid",
"occurredAt": "2026-10-14T10:00:00.000Z",
"actorUserId": "user-uuid-or-null",
"payload": {
"entryId": "entry-uuid",
"entryNumber": 418,
"projectId": "project-uuid",
"title": "Fix login bug",
"type": "bug_report",
"status": "open",
"priority": "high"
}
}
id identifies the event. It is the same on every retry of the same delivery. payload depends on the event; see Payload fields.
Payload fields
| Events | payload fields |
|---|---|
entry.created | entryId, entryNumber, projectId, title, type, status, priority |
entry.updated | The entry.created fields, plus changedFields (the names of the fields that changed) |
entry.status_changed | The entry.created fields, plus previousStatus and newStatus |
entry.assigned | The entry.created fields, plus assignedToId and previousAssigneeId |
entry.commented | The entry.created fields, plus commentId, authorUserId, isInternal and bodyPreview. Sent only for comments the client can see, so isInternal is always false; see Internal notes |
release.created, release.released | releaseId, projectId, name, status |
release.status_changed | The release fields, plus previousStatus and newStatus |
milestone.created, milestone.deleted | milestoneId, projectId, name, status, targetDate |
milestone.updated | The milestone fields, plus changedFields |
milestone.status_changed | The milestone fields, plus previousStatus, newStatus and reachedAt |
milestone.member_added, milestone.member_removed | The milestone fields, plus itemType (release, epic or entry) and itemId |
epic.created, epic.deleted | epicId, projectId, name, status |
epic.updated | The epic fields, plus changedFields |
epic.status_changed | The epic fields, plus previousStatus and newStatus |
cost.approved, cost.rejected | entryId, projectId, amount, currency, decision, reason |
wiki_page.created, wiki_page.deleted | pageId, organizationId, projectId, title, actorId |
wiki_page.updated | The wiki page fields, plus changedFields |
time.logged, time.submitted, time.updated, time.deleted | timeEntryId, organizationId, entryId, projectId, userId, workDate, hours, minutes, status |
time.approved | The time entry fields, plus approverUserId |
time.rejected | The time entry fields, plus rejecterUserId and reason |
time.unlocked | The time entry fields, plus unlockedByUserId and reason |
agreement.signed | agreementId, projectId, clientId, status |
pr.merged | pullRequestId, repositoryFullName, projectRepositoryId, number, mergedByUser, baseBranch, linkedEntryIds, implementedEntryIds, closingEntryIds, changedFiles |
commit.linked | commitId, commitSha, entryId, projectId, linkType, isPrimary |
user.invited | userId, invitationId, pending, organizationId, email, name, role, actorId. For an emailed invitation userId is null, invitationId names the invitation and pending is true; a user provisioned directly has userId and pending: false |
user.linked | The user fields, plus provider and externalId |
user.updated | The user fields, plus changedFields |
user.bulk_invited | organizationId, actorId, count, successCount, failureCount, userIds |
project_member.added, project_member.removed | memberId, organizationId, projectId, userId, roleOverride, actorId |
project_member.updated | The project member fields, plus changedFields |
code_quality.analysis_completed | projectId, sonarProjectKey, branch, branchType (BRANCH or PULL_REQUEST), pullRequest (a pull request only), gateStatus, previousGateStatus, taskId, analysedAt, revision and source (webhook or sweep). previousGateStatus is null when Align knew no gate for the branch yet; taskId is null when source is sweep, that is when Align's own hourly or on-demand refresh found the analysis rather than SonarQube's webhook. Delivered only to the workspace's own subscriptions, never to an app's; see Staff-only events |
Some fields can be null or missing, for example entryNumber, priority and targetDate. A payload can also carry occurredAt and actorUserId. The API reference lists the event names but not their payloads.
Internal notes
An internal note (a comment marked internal, which only the workspace's team can read) is never delivered. No entry.commented delivery is made for one, to any subscription: one created by a workspace admin, by an API key or by a marketplace app. Align's GitHub and Taskflow comment sync, its notifications, its emails and its automation rules skip internal notes in the same way: no automation rule runs on an internal note, so none of its actions (a webhook call, a notification) happens for one. A delivery already queued for a comment that is made internal before it is sent is not sent.
Staff-only events
Some events are for a workspace's staff only. Today that is code_quality.analysis_completed, which reports a SonarQube analysis of a project's code. Such an event is delivered only to subscriptions the workspace created: one an admin created in Align, or one created with one of the workspace's own API keys.
A Marketplace app never receives a staff-only event. Align does not deliver one to a subscription created with your app's key, or to one whose destinationUrl your app's key changed, even if the subscription names the event or uses *. Your app still receives every other event it subscribes to. You can subscribe to the event, but nothing is delivered for it.
Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-Align-Event | The event name, for example entry.created |
X-Align-Delivery-Id | The event id (the same value as id in the body, and the same on every retry) |
X-Align-Timestamp | Unix time in seconds when this attempt was sent |
X-Align-Signature | sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the subscription's hmacSecret |
User-Agent | Align-Outbound-Webhook/1.0 |
Verifying signatures
Verify the signature against the raw request body before you parse it.
Node.js
import crypto from "crypto";
function verifyAlignWebhook(rawBody, headers, hmacSecret) {
const signature = headers["x-align-signature"] ?? ""; // "sha256=abc123..."
const expected =
"sha256=" +
crypto.createHmac("sha256", hmacSecret).update(rawBody, "utf8").digest("hex");
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expBuf);
}
Python (Flask)
import hmac, hashlib
def verify_align_webhook(request, hmac_secret: str) -> bool:
sig = (request.headers.get("X-Align-Signature") or "").removeprefix("sha256=")
expected = hmac.new(hmac_secret.encode(), request.get_data(), hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected)
Replay protection
X-Align-Timestamp is not part of the signed body. You can still use it to reject deliveries that are too old:
const MAX_AGE_SECONDS = 5 * 60; // 5 minutes
const timestamp = parseInt(headers["x-align-timestamp"], 10);
if (Math.abs(Date.now() / 1000 - timestamp) > MAX_AGE_SECONDS) {
return res.status(400).send("Delivery too old");
}
Responding, retries and de-duplication
- Respond with a 2xx status. Align waits up to 10 seconds for a response; do slow work in a background queue.
- A 3xx response is final too. Align does not follow redirects: point the subscription at the final URL.
- A 4xx response is final. Align records the delivery as dead-lettered and does not retry, so return 4xx only when retrying cannot help (for example, a bad signature).
- A 5xx response, a timeout or a network error is retried. Align makes up to five attempts in total, waiting about 2, 4, 8 and 16 seconds between them, then dead-letters the delivery. Retries therefore end within about a minute; use reconciliation to recover anything you missed.
- De-duplicate with
X-Align-Delivery-Id(oridin the body): it is the same on every attempt for an event.
Recommended events
For most integrations, subscribe at least to:
| Event | Why |
|---|---|
entry.created | New work items |
entry.status_changed | Status changes. The payload has previousStatus and newStatus. |
release.released | The release reached released. Use this as the "shipped" signal rather than release.status_changed. |
Time, agreement, code, user and project member events are delivered since 2026-10-28; see Events Align delivers.
Worked examples
entry.status_changed
{
"id": "event-uuid",
"eventName": "entry.status_changed",
"schemaVersion": 1,
"entityType": "entry",
"entityId": "entry-uuid-7890",
"occurredAt": "2026-10-14T11:30:00.000Z",
"actorUserId": "user-uuid-3456",
"payload": {
"entryId": "entry-uuid-7890",
"projectId": "project-uuid-9012",
"title": "Fix login bug",
"type": "bug_report",
"status": "done",
"previousStatus": "deployed",
"newStatus": "done"
}
}
Entry statuses are open, assigned, in_progress, blocked, testing, deployed, done and solved. done follows deployed; solved means closed without a deploy. Map them to your own workflow with a configurable mapping rather than hard-coding them.
release.released
{
"id": "event-uuid",
"eventName": "release.released",
"schemaVersion": 1,
"entityType": "release",
"entityId": "release-uuid-1111",
"occurredAt": "2026-10-14T14:00:00.000Z",
"actorUserId": "user-uuid-3456",
"payload": {
"releaseId": "release-uuid-1111",
"projectId": "project-uuid-9012",
"name": "v2.1.0",
"status": "released"
}
}
A common next step is to fetch GET /api/v1/releases/{releaseId}/evidence and archive it (see Release Evidence API).
cost.approved and cost.rejected
The payload has entryId, projectId, amount (decimal text or null), currency, decision (approved or rejected) and reason.
Events Align delivers
The full list of names is in the API reference under eventNames; the payloads are in Payload fields above. These are the events Align delivers to subscriptions today.
Entries
| Event | When it fires |
|---|---|
entry.created | An entry was created |
entry.updated | An entry's editable fields changed |
entry.status_changed | An entry's status changed |
entry.assigned | An entry's assignee changed, including the first assignment |
entry.commented | A comment was added to an entry |
Releases, milestones and epics
| Event | When it fires |
|---|---|
release.created | A release was created |
release.status_changed | A release's status changed |
release.released | A release reached released |
milestone.created, milestone.updated, milestone.status_changed, milestone.deleted | A milestone was created, edited, changed status or deleted |
milestone.member_added, milestone.member_removed | A release, epic or entry was added to or removed from a milestone |
epic.created, epic.updated, epic.status_changed, epic.deleted | An epic was created, edited, changed status or deleted |
Cost
| Event | When it fires |
|---|---|
cost.approved | An entry's committed cost was approved |
cost.rejected | An entry's committed cost was rejected |
Wiki
| Event | When it fires |
|---|---|
wiki_page.created, wiki_page.updated, wiki_page.deleted | A project wiki page was created, changed or deleted |
Time tracking
| Event | When it fires |
|---|---|
time.logged | A time entry was logged |
time.updated | A draft time entry was edited through the public API |
time.deleted | A draft time entry was deleted through the public API |
time.submitted | A time entry was submitted for approval |
time.approved, time.rejected | A submitted time entry was approved or rejected |
time.unlocked | An approved or locked time entry was unlocked |
Agreements
| Event | When it fires |
|---|---|
agreement.signed | An agreement was signed by all required parties |
Code
| Event | When it fires |
|---|---|
pr.merged | A pull request on a connected repository was merged |
commit.linked | A commit was linked to an entry |
Users and project members
These fire for changes made through the public API (/api/v1/users and /api/v1/projects/{id}/members).
| Event | When it fires |
|---|---|
user.invited | A user was invited by email or provisioned directly |
user.linked | An existing user was linked to an external identity (externalRef) |
user.updated | A user's name or project memberships changed |
user.bulk_invited | A bulk invite finished; the payload carries the counts and the user ids |
project_member.added, project_member.updated, project_member.removed | A project member was added, changed or removed |
Time, agreement, code, user and project member events have been delivered since 2026-10-28. Before that they were accepted but not delivered.
Accepted but not delivered
The API accepts every name in the vocabulary. Two names are accepted but not delivered today, because nothing in Align emits them yet: integration_link.created and integration_link.updated. Poll GET /api/v1/integration-links for those changes instead. See Reconciliation.
Not emitted yet
Some names in the vocabulary have no producer in Align yet, so nothing records or delivers them: integration_link.created, integration_link.updated, subscription.updated, pi_board.updated, pi_board.published and pi_board.deleted. You can subscribe to them, and existing subscriptions keep working, but do not build on them. The OpenAPI spec lists them in x-align-events-not-yet-delivered.
project.transfer_initiated and project.transferred were added to the vocabulary on 2026-10-23. Align already emitted them, but they were dropped as unknown names before then.
Testing your endpoint
Send a test delivery
curl -X POST https://app.alignsoft.us/api/v1/webhooks/$SUBSCRIPTION_ID/test \
-H "Authorization: Bearer $ALIGN_KEY" \
-H "Content-Type: application/json"
Align sends a signed webhook.test event to the subscription's destinationUrl straight away, and responds:
{
"deliveryId": "delivery-uuid",
"subscriptionId": "sub-uuid",
"eventName": "webhook.test",
"firedAt": "2026-10-14T12:00:00.000Z"
}
If the delivery did not succeed, the response also contains deliveryError: your endpoint returned a non-2xx status (including a 3xx, which is not followed, and a 4xx), timed out or refused the connection, or the subscription's destination is one Align no longer delivers to. deliveryError is Align's own message; it never contains your endpoint's response body. The test payload contains subscriptionId, message and triggeredAt.
To receive a delivery under one of the subscription's own event names instead, pass it in the body:
curl -X POST https://app.alignsoft.us/api/v1/webhooks/$SUBSCRIPTION_ID/test \
-H "Authorization: Bearer $ALIGN_KEY" \
-H "Content-Type: application/json" \
-d '{ "eventName": "entry.created" }'
The name must be in the subscription's eventNames; otherwise the request returns 400 validation_error. The payload is still the test payload, not a real entry.
Test deliveries are limited to 10 per organisation per hour. Beyond that the endpoint returns 429 with reason rate_limit and retryAfter in seconds.
Troubleshooting
| Symptom | Likely cause |
|---|---|
404 not_found on the test endpoint | The subscription does not belong to your organisation |
403 forbidden on the test endpoint | The key does not hold write:webhook |
400 validation_error on eventName | The event is not in the subscription's eventNames |
429 rate_limit | More than 10 test deliveries in the last hour |
deliveryError mentions an HTTP 5xx status | Your endpoint failed while handling the request |
deliveryError mentions an HTTP 4xx status | Your endpoint rejected the request, for example after a failed signature check; check your verification code |
deliveryError mentions a redirect | Your endpoint answered 3xx; redirects are not followed, so use the final URL |
deliveryError starts with "Destination not allowed" | The destinationUrl is not an https:// URL on a public host, or its name now resolves to an address that is not public; see Where deliveries can go |
deliveryError with no HTTP status | Your endpoint timed out (10 seconds) or refused the connection; check that it is publicly reachable over HTTPS |
No deliveryError, deliveryId is null, and nothing arrives | Outbound events are off for the organisation's connection; see Outbound events must be on |