Skip to main content

Webhook Integration

Webhooks let Align push domain events to a URL you own — no polling required. When something changes in Align (an entry is created, a release is published, etc.) Align sends an HTTP POST to your endpoint within seconds.

Creating a Subscription

Via the API

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.updated", "release.released"]
}'

The response includes an hmacSecretsave it now. It is shown only once and cannot be retrieved again.

Via the UI

  1. Go to Settings → Developer → Webhooks.
  2. Click Add Subscription.
  3. Enter your endpoint URL and select the events to subscribe to.
  4. Copy the HMAC secret shown on creation.

For most integration partners, this minimal set covers the events that carry commercially significant signals. Subscribe to these at minimum:

EventWhy it matters
entry.createdTrack new work items as they are logged
entry.status_changedSync status transitions to your system
release.releasedCommercially significant. Fires when a release transitions to released — use this to trigger compliance workflows, customer notifications, or CI/CD gates. Do not rely on release.status_changed alone; release.released is the canonical "shipped" signal.
agreement.signedCommercially significant. Fires when all parties have signed an agreement — use this to unlock billing, provisioning, or contract management workflows. The agreementId field is stable and can be used as a foreign key in your system.
time.submittedReact when time is submitted for approval
time.approvedLock approved hours in your billing or payroll system
time.rejectedNotify the submitter to correct and resubmit

Delivery Format

Each delivery is an HTTP POST to your endpoint with the following envelope:

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"eventName": "entry.created",
"schemaVersion": 1,
"entityType": "entry",
"entityId": "entry-uuid",
"occurredAt": "2026-05-14T10:00:00.000Z",
"actorUserId": "user-uuid-or-null",
"payload": {
"entryId": "entry-uuid",
"projectId": "project-uuid",
"title": "Fix login bug",
"type": "bug",
"status": "open",
"occurredAt": "2026-05-14T10:00:00.000Z"
}
}

Worked examples

Each example below includes a Recommended partner action describing the most common integration response.


time.logged — A new time entry was created (status: draft):

{
"id": "delivery-uuid",
"eventName": "time.logged",
"schemaVersion": 1,
"entityType": "time_entry",
"entityId": "te-uuid-1234",
"occurredAt": "2026-05-26T09:00:00Z",
"actorUserId": "user-uuid-3456",
"payload": {
"timeEntryId": "te-uuid-1234",
"organizationId": "org-uuid-5678",
"projectId": "project-uuid-9012",
"userId": "user-uuid-3456",
"entryId": "entry-uuid-7890",
"workDate": "2026-05-26",
"hours": "2.5",
"minutes": 150,
"status": "draft",
"occurredAt": "2026-05-26T09:00:00Z"
}
}

Recommended partner action: Upsert the time entry in your system using timeEntryId as the stable key. Resolve the project using your entity-link table and projectId. Use workDate + userId + projectId as a natural deduplication key. Do not treat draft entries as billable yet — wait for time.approved.


time.submitted — A logger submitted a draft time entry for manager approval:

{
"id": "delivery-uuid",
"eventName": "time.submitted",
"schemaVersion": 1,
"entityType": "time_entry",
"entityId": "te-uuid-1234",
"occurredAt": "2026-05-27T08:00:00Z",
"actorUserId": "user-uuid-3456",
"payload": {
"timeEntryId": "te-uuid-1234",
"organizationId": "org-uuid-5678",
"projectId": "project-uuid-9012",
"userId": "user-uuid-3456",
"entryId": "entry-uuid-7890",
"workDate": "2026-05-26",
"hours": "2.5",
"minutes": 150,
"status": "submitted",
"occurredAt": "2026-05-27T08:00:00Z"
}
}

Recommended partner action: Update your local record to status=submitted. If your system has an approval queue, enqueue a notification to the project manager. Do not post to billing until time.approved fires.


time.approved — A manager approved a submitted time entry (entry is now locked):

{
"id": "delivery-uuid",
"eventName": "time.approved",
"schemaVersion": 1,
"entityType": "time_entry",
"entityId": "te-uuid-1234",
"occurredAt": "2026-05-27T12:00:00Z",
"actorUserId": "manager-uuid-2222",
"payload": {
"timeEntryId": "te-uuid-1234",
"organizationId": "org-uuid-5678",
"projectId": "project-uuid-9012",
"userId": "user-uuid-3456",
"entryId": "entry-uuid-7890",
"workDate": "2026-05-26",
"hours": "2.5",
"minutes": 150,
"status": "locked",
"approverUserId": "manager-uuid-2222",
"occurredAt": "2026-05-27T12:00:00Z"
}
}

Recommended partner action: Mark the time entry as approved and billable in your system. The hours field (text, e.g. "2.5") is the canonical stored value. Feed it into payroll or cost-rollup calculations. The entry is now locked in Align and will not change unless an admin fires time.unlocked.


time.rejected — A manager rejected a submitted time entry:

{
"id": "delivery-uuid",
"eventName": "time.rejected",
"schemaVersion": 1,
"entityType": "time_entry",
"entityId": "te-uuid-1234",
"occurredAt": "2026-05-27T13:00:00Z",
"actorUserId": "manager-uuid-2222",
"payload": {
"timeEntryId": "te-uuid-1234",
"organizationId": "org-uuid-5678",
"projectId": "project-uuid-9012",
"userId": "user-uuid-3456",
"entryId": "entry-uuid-7890",
"workDate": "2026-05-26",
"hours": "2.5",
"minutes": 150,
"status": "rejected",
"rejecterUserId": "manager-uuid-2222",
"reason": "Please split by task — one entry per entry card.",
"occurredAt": "2026-05-27T13:00:00Z"
}
}

Recommended partner action: Update your local record to status=rejected. Surface the reason to the submitter so they know what to correct. The submitter can edit and resubmit in Align; you will receive a fresh time.submitted event when they do.


time.unlocked — An admin unlocked a previously-approved (locked) time entry:

{
"id": "delivery-uuid",
"eventName": "time.unlocked",
"schemaVersion": 1,
"entityType": "time_entry",
"entityId": "te-uuid-1234",
"occurredAt": "2026-05-28T09:30:00Z",
"actorUserId": "admin-uuid-9999",
"payload": {
"timeEntryId": "te-uuid-1234",
"organizationId": "org-uuid-5678",
"projectId": "project-uuid-9012",
"userId": "user-uuid-3456",
"entryId": "entry-uuid-7890",
"workDate": "2026-05-26",
"hours": "2.5",
"minutes": 150,
"status": "locked",
"unlockedByUserId": "admin-uuid-9999",
"reason": "Incorrect hours — needs correction before invoice run.",
"occurredAt": "2026-05-28T09:30:00Z"
}
}

Recommended partner action: If you have already posted this entry to billing or payroll, reverse the posting and await a fresh time.approved event after the correction. Do not treat the entry as final until you receive a new time.approved.


time.updated — A draft time entry's fields were changed via the API:

{
"id": "delivery-uuid",
"eventName": "time.updated",
"schemaVersion": 1,
"entityType": "time_entry",
"entityId": "te-uuid-1234",
"occurredAt": "2026-05-26T10:15:00Z",
"actorUserId": "user-uuid-3456",
"payload": {
"timeEntryId": "te-uuid-1234",
"organizationId": "org-uuid-5678",
"projectId": "project-uuid-9012",
"userId": "user-uuid-3456",
"entryId": "entry-uuid-7890",
"workDate": "2026-05-26",
"hours": "3.0",
"minutes": 180,
"status": "draft",
"changedFields": ["minutes", "hours"],
"occurredAt": "2026-05-26T10:15:00Z"
}
}

Recommended partner action: Upsert the entry using timeEntryId. Use the changedFields array to decide which local fields to update, avoiding unnecessary writes. Since the entry is still draft, it is not billable yet.


entry.status_changed — An entry moved to a new status:

{
"id": "delivery-uuid",
"eventName": "entry.status_changed",
"schemaVersion": 1,
"entityType": "entry",
"entityId": "entry-uuid-7890",
"occurredAt": "2026-05-26T11:30:00Z",
"actorUserId": "user-uuid-3456",
"payload": {
"entryId": "entry-uuid-7890",
"projectId": "project-uuid-9012",
"title": "Fix login bug",
"type": "bug",
"status": "solved",
"previousStatus": "in_progress",
"newStatus": "solved",
"occurredAt": "2026-05-26T11:30:00Z"
}
}

Recommended partner action: Sync newStatus to your system using entryId as the stable key. If your system has a workflow, map Align statuses to your own using a configurable field mapping rather than hardcoding status names.


release.released — A release was published (the canonical "shipped" signal):

{
"id": "delivery-uuid",
"eventName": "release.released",
"schemaVersion": 1,
"entityType": "release",
"entityId": "release-uuid-1111",
"occurredAt": "2026-05-26T14:00:00Z",
"actorUserId": "user-uuid-3456",
"payload": {
"releaseId": "release-uuid-1111",
"projectId": "project-uuid-9012",
"name": "v2.1.0",
"status": "released",
"occurredAt": "2026-05-26T14:00:00Z"
}
}

Recommended partner action: Use releaseId as the stable key. Trigger any downstream automation that should happen on release (compliance checks, customer notifications, evidence archiving via GET /releases/:id/evidence). Prefer release.released over release.status_changed for this — it fires exactly once when the status enters released.


user.invited — A user was invited to (or provisioned in) the workspace:

{
"id": "delivery-uuid",
"eventName": "user.invited",
"schemaVersion": 1,
"entityType": "user",
"entityId": "user-uuid-7890",
"occurredAt": "2026-05-26T09:00:00Z",
"actorUserId": "admin-uuid-1234",
"payload": {
"userId": "user-uuid-7890",
"organizationId": "org-uuid-5678",
"email": "alice@example.com",
"name": "Alice Smith",
"role": "developer",
"actorId": "admin-uuid-1234",
"occurredAt": "2026-05-26T09:00:00Z"
}
}

Recommended partner action: Upsert the user in your local member table using userId as the stable key and email as the natural key. Store the role — it determines which API actions the user can perform (e.g. manager can approve time entries). This event fires for both UI invites and API-provisioned users (POST /users/invite).


user.updated — A user's name or metadata was updated via the public API:

{
"id": "delivery-uuid",
"eventName": "user.updated",
"schemaVersion": 1,
"entityType": "user",
"entityId": "user-uuid-7890",
"occurredAt": "2026-05-26T14:00:00Z",
"actorUserId": "admin-uuid-1234",
"payload": {
"userId": "user-uuid-7890",
"organizationId": "org-uuid-5678",
"email": "alice@example.com",
"name": "Alice Smith-Jones",
"role": "developer",
"actorId": "admin-uuid-1234",
"changedFields": ["name"],
"occurredAt": "2026-05-26T14:00:00Z"
}
}

Recommended partner action: Apply the changed fields to your local record. Use the changedFields array to avoid overwriting unchanged local values. Note that role changes made inside Align (outside the API) will also trigger this event — keep your local role copy in sync.


agreement.signed — An agreement was signed by all required parties:

{
"id": "delivery-uuid",
"eventName": "agreement.signed",
"schemaVersion": 1,
"entityType": "agreement",
"entityId": "agreement-uuid-2222",
"occurredAt": "2026-05-26T15:00:00Z",
"actorUserId": null,
"payload": {
"agreementId": "agreement-uuid-2222",
"projectId": "project-uuid-9012",
"clientId": "client-uuid-3333",
"status": "signed",
"occurredAt": "2026-05-26T15:00:00Z"
}
}

Recommended partner action: Use agreementId as the stable foreign key. Unlock downstream workflows that require a signed agreement — billing provisioning, contract filing, or notification to the account manager. actorUserId will be null for system-generated completions (e-sign provider callback) and set to a userId for admin-marked completions.

Request Headers

HeaderDescription
X-Align-EventEvent name (e.g. entry.created)
X-Align-Delivery-IdUnique UUID for this delivery attempt
X-Align-TimestampUnix epoch seconds at delivery time
X-Align-SignatureHMAC-SHA256 of the raw body, formatted sha256=<hex>
Content-Typeapplication/json
User-AgentAlign-Outbound-Webhook/1.0

Verifying Signatures

Always verify the signature before processing a delivery.

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

Use the X-Align-Timestamp header to reject stale deliveries:

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 to Deliveries

  • Respond with 2xx quickly (within 5 seconds). Do heavy work in a background queue.
  • Non-2xx responses, timeouts, and network errors trigger automatic retries with exponential backoff for up to 24 hours.
  • After 24 hours, the delivery is dead-lettered and no further retries are attempted.

De-duplicating Retries

Use X-Align-Delivery-Id to de-duplicate. Store processed delivery IDs in a cache or database with a TTL of at least 25 hours.

Available Events

Subscribe to one or more events by name. Unknown event names are rejected with HTTP 400. Supplying an unknown name will return { "error": "unknown_event_names", "invalid": ["..."] }.

Entry events

EventWhen it fires
entry.createdA new entry is created
entry.updatedAn entry's fields change
entry.status_changedAn entry status transitions
entry.assignedAn entry's assignee changes
entry.commentedA comment is posted on an entry

Release events

EventWhen it fires
release.createdA new release is created
release.releasedA release moves to the released state (recommended)
release.status_changedAny release status change

Agreement events

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

Cost events

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

Time tracking events

EventWhen it fires
time.loggedA time entry was created (status: draft)
time.submittedA time entry was submitted for approval
time.approvedA time entry was approved and locked
time.rejectedA time entry was rejected
time.unlockedAn approved time entry was unlocked by an admin
time.updatedA draft time entry's fields were changed via the API

User events

EventWhen it fires
user.invitedA user was invited to or provisioned in the workspace
user.updatedA user's name or metadata was updated

Code and PR events

EventWhen it fires
pr.mergedA linked pull request was merged
commit.linkedA commit was linked to an entry

For the complete event vocabulary with payload shapes, see the Event Reference page.

Managing Subscriptions

  • List subscriptionsGET /api/v1/webhooks
  • Update a subscriptionPATCH /api/v1/webhooks/:id
  • Delete a subscriptionDELETE /api/v1/webhooks/:id
  • Rotate the HMAC secretPOST /api/v1/webhooks/:id/rotate-secret (returns the new secret once)

Testing Your Integration

Bootstrapping a local test environment

The seed script creates an isolated test organisation, admin user, API key, and project in one step — no manual setup required.

# From the project root (requires DATABASE_URL to be set)
npm run scripts:seed-test-org

The script prints everything you need to stdout:

============================================================
CREDENTIALS SUMMARY — save these before closing
============================================================
Organisation ID : <org-uuid>
User Email : testadmin+2026-05-28T12-00-00@align-test.local
User Password : TestPassword1!
API Key : ako_<64-hex-chars>
Project ID : <project-uuid>

Each run creates a fresh, timestamped set of records so re-running is always safe.


Step 1 — Create a webhook subscription

Use the API key printed by the seed script:

ALIGN_KEY="ako_<your-key>"

curl https://app.alignsoft.us/api/v1/webhooks \
-H "Authorization: Bearer $ALIGN_KEY" \
-H "Content-Type: application/json" \
-d '{
"destinationUrl": "https://your-endpoint.example.com/hook",
"eventNames": ["entry.created", "entry.status_changed"]
}'

Save the id and hmacSecret from the response — the secret is shown only once.


Step 2 — Fire a test delivery

SUBSCRIPTION_ID="<id from step 1>"

curl -X POST https://app.alignsoft.us/api/v1/webhooks/$SUBSCRIPTION_ID/test \
-H "Authorization: Bearer $ALIGN_KEY" \
-H "Content-Type: application/json"

The endpoint sends a synthetic webhook.test event to your destinationUrl — a real HMAC-signed POST — and returns:

{
"deliveryId": "delivery-uuid",
"subscriptionId": "sub-uuid",
"eventName": "webhook.test",
"firedAt": "2026-05-28T12:00:00.000Z"
}

Optional — test with a specific event type

Pass eventName in the request body to send a payload shaped like one of the subscription's configured events instead of the default webhook.test:

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 eventName must be in the subscription's eventNames list; otherwise the request returns HTTP 400.

Rate limiting

The test endpoint is limited to 10 calls per organisation per hour. Exceeding the limit returns HTTP 429 with a retryAfter field (seconds).


Step 3 — Verify the delivery result

curl https://app.alignsoft.us/api/v1/webhooks/$SUBSCRIPTION_ID/deliveries \
-H "Authorization: Bearer $ALIGN_KEY"

Each delivery record includes status (succeeded, failed, or dead_letter), responseCode, and responseBody so you can see exactly what your endpoint returned.

Use the deliveryId from the test response to find the specific attempt:

curl https://app.alignsoft.us/api/v1/webhooks/$SUBSCRIPTION_ID/deliveries/$DELIVERY_ID \
-H "Authorization: Bearer $ALIGN_KEY"

Troubleshooting checklist

SymptomLikely cause
404 not_found on the test endpointThe subscription ID doesn't belong to your org, or no webhook connection exists yet
400 validation_error on eventNameThe supplied event is not in the subscription's eventNames list
429 rate_limitMore than 10 test calls in the last hour
Delivery status dead_letter, response code 401Your endpoint rejected the signature — check your HMAC verification logic
Delivery status failed, no response codeYour endpoint timed out or refused the connection — check it's publicly reachable