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 hmacSecret — save it now. It is shown only once and cannot be retrieved again.
Via the UI
- Go to Settings → Developer → Webhooks.
- Click Add Subscription.
- Enter your endpoint URL and select the events to subscribe to.
- Copy the HMAC secret shown on creation.
Recommended Subscription Set
For most integration partners, this minimal set covers the events that carry commercially significant signals. Subscribe to these at minimum:
| Event | Why it matters |
|---|---|
entry.created | Track new work items as they are logged |
entry.status_changed | Sync status transitions to your system |
release.released | Commercially 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.signed | Commercially 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.submitted | React when time is submitted for approval |
time.approved | Lock approved hours in your billing or payroll system |
time.rejected | Notify 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
timeEntryIdas the stable key. Resolve the project using your entity-link table andprojectId. UseworkDate + userId + projectIdas a natural deduplication key. Do not treatdraftentries as billable yet — wait fortime.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 untiltime.approvedfires.
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
hoursfield (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 firestime.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 thereasonto the submitter so they know what to correct. The submitter can edit and resubmit in Align; you will receive a freshtime.submittedevent 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.approvedevent after the correction. Do not treat the entry as final until you receive a newtime.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 thechangedFieldsarray to decide which local fields to update, avoiding unnecessary writes. Since the entry is stilldraft, 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
newStatusto your system usingentryIdas 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
releaseIdas the stable key. Trigger any downstream automation that should happen on release (compliance checks, customer notifications, evidence archiving viaGET /releases/:id/evidence). Preferrelease.releasedoverrelease.status_changedfor this — it fires exactly once when the status entersreleased.
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
userIdas the stable key androle— it determines which API actions the user can perform (e.g.managercan 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
changedFieldsarray to avoid overwriting unchanged local values. Note thatrolechanges made inside Align (outside the API) will also trigger this event — keep your localrolecopy 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
agreementIdas the stable foreign key. Unlock downstream workflows that require a signed agreement — billing provisioning, contract filing, or notification to the account manager.actorUserIdwill benullfor system-generated completions (e-sign provider callback) and set to a userId for admin-marked completions.
Request Headers
| Header | Description |
|---|---|
X-Align-Event | Event name (e.g. entry.created) |
X-Align-Delivery-Id | Unique UUID for this delivery attempt |
X-Align-Timestamp | Unix epoch seconds at delivery time |
X-Align-Signature | HMAC-SHA256 of the raw body, formatted sha256=<hex> |
Content-Type | application/json |
User-Agent | Align-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
| Event | When it fires |
|---|---|
entry.created | A new entry is created |
entry.updated | An entry's fields change |
entry.status_changed | An entry status transitions |
entry.assigned | An entry's assignee changes |
entry.commented | A comment is posted on an entry |
Release events
| Event | When it fires |
|---|---|
release.created | A new release is created |
release.released | A release moves to the released state (recommended) |
release.status_changed | Any release status change |
Agreement events
| Event | When it fires |
|---|---|
agreement.signed | An agreement was signed by all required parties (recommended) |
Cost events
| Event | When it fires |
|---|---|
cost.approved | An entry's committed cost was approved |
cost.rejected | An entry's committed cost was rejected |
Time tracking events
| Event | When it fires |
|---|---|
time.logged | A time entry was created (status: draft) |
time.submitted | A time entry was submitted for approval |
time.approved | A time entry was approved and locked |
time.rejected | A time entry was rejected |
time.unlocked | An approved time entry was unlocked by an admin |
time.updated | A draft time entry's fields were changed via the API |
User events
| Event | When it fires |
|---|---|
user.invited | A user was invited to or provisioned in the workspace |
user.updated | A user's name or metadata was updated |
Code and PR events
| Event | When it fires |
|---|---|
pr.merged | A linked pull request was merged |
commit.linked | A commit was linked to an entry |
For the complete event vocabulary with payload shapes, see the Event Reference page.
Managing Subscriptions
- List subscriptions —
GET /api/v1/webhooks - Update a subscription —
PATCH /api/v1/webhooks/:id - Delete a subscription —
DELETE /api/v1/webhooks/:id - Rotate the HMAC secret —
POST /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
| Symptom | Likely cause |
|---|---|
404 not_found on the test endpoint | The subscription ID doesn't belong to your org, or no webhook connection exists yet |
400 validation_error on eventName | The supplied event is not in the subscription's eventNames list |
429 rate_limit | More than 10 test calls in the last hour |
Delivery status dead_letter, response code 401 | Your endpoint rejected the signature — check your HMAC verification logic |
Delivery status failed, no response code | Your endpoint timed out or refused the connection — check it's publicly reachable |