Skip to main content

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"
}'
  • destinationUrl must be an https:// URL on a public host. Otherwise the request returns 400 validation_error. See Where deliveries can go.
  • eventNames must be a non-empty array of names from the event vocabulary. An unknown name returns 400 with { "error": "unknown_event_names", "invalid": ["..."] }.
  • description is 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:

  1. Click Add subscription.
  2. 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 *.
  3. 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​

ActionRequestPermission
ListGET /api/v1/webhooksread:webhook
Get oneGET /api/v1/webhooks/{id}read:webhook
Update destinationUrl, eventNames, description or enabledPATCH /api/v1/webhooks/{id}write:webhook
DeleteDELETE /api/v1/webhooks/{id}delete:webhook
Send a test deliveryPOST /api/v1/webhooks/{id}/testwrite: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 names localhost and *.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 destinationUrl to 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​

Eventspayload fields
entry.createdentryId, entryNumber, projectId, title, type, status, priority
entry.updatedThe entry.created fields, plus changedFields (the names of the fields that changed)
entry.status_changedThe entry.created fields, plus previousStatus and newStatus
entry.assignedThe entry.created fields, plus assignedToId and previousAssigneeId
entry.commentedThe 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.releasedreleaseId, projectId, name, status
release.status_changedThe release fields, plus previousStatus and newStatus
milestone.created, milestone.deletedmilestoneId, projectId, name, status, targetDate
milestone.updatedThe milestone fields, plus changedFields
milestone.status_changedThe milestone fields, plus previousStatus, newStatus and reachedAt
milestone.member_added, milestone.member_removedThe milestone fields, plus itemType (release, epic or entry) and itemId
epic.created, epic.deletedepicId, projectId, name, status
epic.updatedThe epic fields, plus changedFields
epic.status_changedThe epic fields, plus previousStatus and newStatus
cost.approved, cost.rejectedentryId, projectId, amount, currency, decision, reason
wiki_page.created, wiki_page.deletedpageId, organizationId, projectId, title, actorId
wiki_page.updatedThe wiki page fields, plus changedFields
time.logged, time.submitted, time.updated, time.deletedtimeEntryId, organizationId, entryId, projectId, userId, workDate, hours, minutes, status
time.approvedThe time entry fields, plus approverUserId
time.rejectedThe time entry fields, plus rejecterUserId and reason
time.unlockedThe time entry fields, plus unlockedByUserId and reason
agreement.signedagreementId, projectId, clientId, status
pr.mergedpullRequestId, repositoryFullName, projectRepositoryId, number, mergedByUser, baseBranch, linkedEntryIds, implementedEntryIds, closingEntryIds, changedFiles
commit.linkedcommitId, commitSha, entryId, projectId, linkType, isPrimary
user.inviteduserId, 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.linkedThe user fields, plus provider and externalId
user.updatedThe user fields, plus changedFields
user.bulk_invitedorganizationId, actorId, count, successCount, failureCount, userIds
project_member.added, project_member.removedmemberId, organizationId, projectId, userId, roleOverride, actorId
project_member.updatedThe project member fields, plus changedFields
code_quality.analysis_completedprojectId, 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​

HeaderValue
Content-Typeapplication/json
X-Align-EventThe event name, for example entry.created
X-Align-Delivery-IdThe event id (the same value as id in the body, and the same on every retry)
X-Align-TimestampUnix time in seconds when this attempt was sent
X-Align-Signaturesha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the subscription's hmacSecret
User-AgentAlign-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 (or id in the body): it is the same on every attempt for an event.

For most integrations, subscribe at least to:

EventWhy
entry.createdNew work items
entry.status_changedStatus changes. The payload has previousStatus and newStatus.
release.releasedThe 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​

EventWhen it fires
entry.createdAn entry was created
entry.updatedAn entry's editable fields changed
entry.status_changedAn entry's status changed
entry.assignedAn entry's assignee changed, including the first assignment
entry.commentedA comment was added to an entry

Releases, milestones and epics​

EventWhen it fires
release.createdA release was created
release.status_changedA release's status changed
release.releasedA release reached released
milestone.created, milestone.updated, milestone.status_changed, milestone.deletedA milestone was created, edited, changed status or deleted
milestone.member_added, milestone.member_removedA release, epic or entry was added to or removed from a milestone
epic.created, epic.updated, epic.status_changed, epic.deletedAn epic was created, edited, changed status or deleted

Cost​

EventWhen it fires
cost.approvedAn entry's committed cost was approved
cost.rejectedAn entry's committed cost was rejected

Wiki​

EventWhen it fires
wiki_page.created, wiki_page.updated, wiki_page.deletedA project wiki page was created, changed or deleted

Time tracking​

EventWhen it fires
time.loggedA time entry was logged
time.updatedA draft time entry was edited through the public API
time.deletedA draft time entry was deleted through the public API
time.submittedA time entry was submitted for approval
time.approved, time.rejectedA submitted time entry was approved or rejected
time.unlockedAn approved or locked time entry was unlocked

Agreements​

EventWhen it fires
agreement.signedAn agreement was signed by all required parties

Code​

EventWhen it fires
pr.mergedA pull request on a connected repository was merged
commit.linkedA 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).

EventWhen it fires
user.invitedA user was invited by email or provisioned directly
user.linkedAn existing user was linked to an external identity (externalRef)
user.updatedA user's name or project memberships changed
user.bulk_invitedA bulk invite finished; the payload carries the counts and the user ids
project_member.added, project_member.updated, project_member.removedA 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​

SymptomLikely cause
404 not_found on the test endpointThe subscription does not belong to your organisation
403 forbidden on the test endpointThe key does not hold write:webhook
400 validation_error on eventNameThe event is not in the subscription's eventNames
429 rate_limitMore than 10 test deliveries in the last hour
deliveryError mentions an HTTP 5xx statusYour endpoint failed while handling the request
deliveryError mentions an HTTP 4xx statusYour endpoint rejected the request, for example after a failed signature check; check your verification code
deliveryError mentions a redirectYour 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 statusYour endpoint timed out (10 seconds) or refused the connection; check that it is publicly reachable over HTTPS
No deliveryError, deliveryId is null, and nothing arrivesOutbound events are off for the organisation's connection; see Outbound events must be on