Skip to main content

Align Integration — Developer Reference

Full technical specification for the Illumera–Align bi-directional integration. Use this document to maintain, extend, or debug the integration.

Architecture Overview

┌──────────────────────────────────────────────────────────────────┐
│ Illumera API Server │
│ │
│ Route handlers │
│ (projects, engagements, ┌──────────────────────────┐ │
│ time-entries) ───enqueue──▶ │ align_sync_outbox table │ │
│ └────────────┬─────────────┘ │
│ │ poll every 30s │
│ ┌────────────▼─────────────┐ │
│ │ AlignSyncWorker │ │
│ │ (alignSyncWorker.ts) │ │
│ └────────────┬─────────────┘ │
│ │ HTTP calls │
│ ▼ │
│ Align REST API │
│ (app.alignsoft.us/api/v1) │
│ │
│ POST /webhooks/align ◀─── Align webhook push ─────────────── │
│ (webhooks.ts) │
│ │ │
│ ▼ │
│ align_webhook_deliveries table │
│ │ │
│ ▼ │
│ handleAlignEvent() → updates time_entries table │
│ │
│ Nightly cron: AlignReconcileWorker │
│ (alignReconcileWorker.ts) → align_reconciliation_log │
└──────────────────────────────────────────────────────────────────┘

Outbound flow (Illumera → Align): Route handlers call alignSync.ts helpers, which insert records into align_sync_outbox. The AlignSyncWorker polls the outbox every ~30 seconds, processes items in batches of 10, and calls AlignGateway methods to hit the Align REST API. On success, entity ID mappings are written to align_entity_links. On failure, exponential backoff is applied (see Outbox Worker).

Inbound flow (Align → Illumera): Align sends HTTP POST webhooks to POST /api/webhooks/align. The handler verifies the HMAC-SHA256 signature, deduplicates by X-Align-Delivery-Id, records the delivery in align_webhook_deliveries, and dispatches to a per-event handler. Handlers currently update time_entries status fields.

Database Schema

All Align tables are defined in lib/db/src/schema/align.ts.

align_connections

One row per company–Align connection. There is at most one active connection per (tenantId, companyId) pair.

ColumnTypeNullableDescription
idtext PKNoRandom UUID
tenant_idtext FK→tenantsNoOwning tenant
company_idtext FK→company_profilesNoOwning company
encrypted_api_keytextNoAES-256-GCM encrypted Align API key
key_last_fourtextNoLast 4 chars of the key (display only)
align_org_idtextNoAlign organization UUID (from /me)
align_key_scopetextNo"organization" (project-scoped keys are rejected)
webhook_subscription_idtextYesAlign webhook subscription ID
webhook_secrettextYesAES-256-GCM encrypted HMAC secret (one-time from Align)
webhook_urltextYesRegistered webhook endpoint URL
statusenumNoactive / error / disconnected
last_sync_attimestamptzYesWhen the most recent outbox item succeeded
last_error_attimestamptzYesWhen the most recent failure was recorded
last_error_messagetextYesTruncated error from the last failed item
created_attimestamptzNoRow creation timestamp
updated_attimestamptzNoAuto-updated on every write

Maps an Illumera entity (by UUID) to an Align entity (by Align ID). Written by the sync worker after each successful create call.

ColumnTypeNullableDescription
idtext PKNoRandom UUID
tenant_idtext FK→tenantsNoOwning tenant
company_idtext FK→company_profilesNoOwning company
providertextNoAlways "illumera"
external_typetextNoIllumera entity type: project, user, project_member, time_entry, wiki_page
external_idtextNoIllumera UUID for this entity
align_entity_typetextNoAlign entity type
align_entity_idtextNoAlign entity ID returned by the API
created_attimestamptzNo
updated_attimestamptzNoAuto-updated

Unique constraints: (tenant_id, external_type, external_id) and (tenant_id, align_entity_type, align_entity_id).

Note on project_member: The align_entity_id for project members is a composite "<alignProjectId>:<alignUserId>" to ensure uniqueness when the same person is a member of multiple projects.

time_entries

Stores time entries logged by talent through Illumera. Each entry is associated with a specific engagement and project.

ColumnTypeNullableDescription
idtext PKNoRandom UUID
tenant_idtext FK→tenantsNo
company_idtext FK→company_profilesNo
project_idtext FK→projectsNo
engagement_idtext FK→engagementsNo
person_idtext FK→person_profilesNoThe talent who logged hours
work_datetextNoISO date string (YYYY-MM-DD)
minutesintegerNoDuration in minutes
notestextYesOptional description / notes
statusenumNodraft / submitted / approved / rejected / locked / invoiced
rejection_reasontextYesPopulated by time.rejected webhook
sourceenumNoillumera (created here) or align (originated in Align)
sync_statusenumNopending / synced / error
sync_errortextYesLast sync error for this entry
is_adjustmentbooleanNotrue for correction entries
adjusts_entry_idtextYesID of the entry this corrects
deleted_attimestamptzYesSoft-delete timestamp
created_attimestamptzNo
updated_attimestamptzNoAuto-updated

align_webhook_deliveries

Audit log of every inbound Align webhook delivery.

ColumnTypeNullableDescription
idtext PKNoInternal UUID
delivery_idtext UNIQUENoX-Align-Delivery-Id header value (used for deduplication)
event_nametextNoe.g. time.approved
tenant_idtextYesResolved from align_org_id lookup
company_idtextYesResolved from connection lookup
payloadjsonbYesFull request body
statusenumNoreceived / processed / failed
received_attimestamptzNo
processed_attimestamptzYesSet when handler completes
errortextYesHandler error message

align_sync_outbox

Durable outbox queue for all outbound sync operations. The worker claims rows atomically using FOR UPDATE SKIP LOCKED.

ColumnTypeNullableDescription
idtext PKNoRandom UUID
tenant_idtext FK→tenantsNo
company_idtext FK→company_profilesNo
entity_typetextNoproject, user, project_member, time_entry, time_entry_submit, wiki_page
local_idtextNoIllumera entity UUID
operationenumNoupsert / delete
payloadjsonbYesTyped payload (see Sync Triggers)
statusenumNopending / processing / done / failed
attemptsintegerNoNumber of delivery attempts
last_attempt_attimestamptzYes
next_retry_attimestamptzNoEarliest time the worker should attempt this item
errortextYesLast error from the worker
created_attimestamptzNo

align_reconciliation_log

Records discrepancies found by the nightly reconciliation job.

ColumnTypeNullableDescription
idtext PKNoRandom UUID
tenant_idtext FK→tenantsNo
project_idtext FK→projectsYesThe project where the discrepancy was found
fieldtextNoWhat was compared: approved_minutes, approved_cost_cents, entity_link.<type>
illumera_valuetextYesIllumera's recorded value
align_valuetextYesAlign's returned value
detected_attimestamptzNoTimestamp of the reconciliation run

Entity Mapping Table

Illumera entityAlign entityexternalType used
projects.idAlign projectproject
users.idAlign useruser
engagements.id (as project member)Align project memberproject_member
time_entries.idAlign time entrytime_entry
projects.id (as wiki page)Align wiki pagewiki_page

externalRef Contract

Every create operation sent to Align includes an externalRef object in the request body:

{
"provider": "illumera",
"externalType": "<entity_type>",
"externalId": "<illumera_uuid>"
}

The externalType values map to the entity mapping table above. Align stores this reference and includes it in inbound webhook payloads for project_member.added and integration_link.created events, enabling bidirectional reconciliation.

Why this makes retries safe: Align's POST endpoints accept an Idempotency-Key header (24-hour window). Illumera uses the localId (Illumera UUID) as the idempotency key for every create call. This means if the network drops after Align persists the record but before Illumera receives the response, a retry with the same idempotency key returns the original response without creating a duplicate.

AlignGateway API

AlignGateway (artifacts/api-server/src/lib/alignGateway.ts) is the typed HTTP client for the Align REST API. All requests use Authorization: Bearer <key> and Content-Type: application/json. Non-2xx responses throw AlignApiError; HTTP 429 throws AlignRateLimitError.

An optional Idempotency-Key header is sent on all create/mutate calls using the Illumera entity UUID. Align caches the response for 24 hours, making retries safe.

Error classes:

class AlignApiError extends Error {
status: number; // HTTP status code
body: unknown; // Parsed response body
}

class AlignRateLimitError extends Error {
retryAfterSeconds: number; // From Retry-After header
}

GET /me — Validate API key

Used at connection time to confirm the key is valid and organization-scoped.

Response:

{
"apiKeyId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"organizationId": "7b1e2c3d-0000-0000-0000-000000000001",
"scope": "organization",
"scopedProjectId": null,
"permissions": ["read:project", "write:entry"],
"createdByUserId": "9a8b7c6d-0000-0000-0000-000000000002",
"lastUsedAt": "2025-01-15T10:30:00Z"
}

Illumera checks scope === "organization" and rejects the key if scope === "project".

POST /users/invite — Provision a talent user

Called by the outbox worker when syncing a user entity for the first time.

Request:

{
"email": "alice@example.com",
"name": "Alice Smith",
"role": "developer",
"sendInvite": false,
"externalRef": {
"provider": "illumera",
"externalType": "user",
"externalId": "<illumera-users.id>"
}
}

Response:

{
"userId": "9a8b7c6d-0000-0000-0000-000000000002",
"inviteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"created": true,
"status": "invited"
}

The returned userId is stored in align_entity_links and used for all future member/time-entry calls for this user.

POST /users/bulk-invite — Provision multiple users at once

Available in AlignGateway but not yet used by the outbox worker (which calls inviteUser per user). Accepts up to 50 invites per call.

Request:

{
"invites": [
{
"email": "bob@example.com",
"name": "Bob Jones",
"role": "developer",
"externalRef": { "provider": "illumera", "externalType": "user", "externalId": "<uuid>" }
}
],
"sendInvite": false
}

Response:

{ "results": [...], "successCount": 1, "failureCount": 0 }

PATCH /users/:id — Update user profile

Called when a Clerk user.updated webhook triggers a re-sync for a user with active engagements.

Request:

{ "name": "Alice Smith-Jones", "email": "alice.new@example.com" }

Response: Full User object (see GET /users/:id).

POST /projects — Create a project

Called by the outbox worker when syncing a project entity for the first time.

Request:

{
"name": "Federal Data Platform Modernization",
"description": "GSA data modernization program",
"timeBillingEnabled": true,
"externalRef": {
"provider": "illumera",
"externalType": "project",
"externalId": "<illumera-projects.id>"
}
}

Response:

{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Federal Data Platform Modernization",
"description": "GSA data modernization program",
"status": "active",
"organizationId": "7b1e2c3d-0000-0000-0000-000000000001",
"clientId": "9a8b7c6d-0000-0000-0000-000000000002",
"createdAt": "2025-01-01T00:00:00Z"
}

The returned id is stored in align_entity_links with externalType = "project".

PATCH /projects/:id — Update a project

Called on subsequent project syncs (name, description, or status change).

Request: Same fields as create; all optional. Illumera sends name, description.

POST /projects/:id/members/:userId — Add talent to a project

Called when an engagement is accepted. Both alignProjectId and alignUserId must already be known (resolved from align_entity_links).

Request:

{
"roleOverride": "developer",
"externalRef": {
"provider": "illumera",
"externalType": "project_member",
"externalId": "<illumera-engagements.id>"
}
}

Response:

{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"projectId": "7b1e2c3d-0000-0000-0000-000000000001",
"userId": "9a8b7c6d-0000-0000-0000-000000000002",
"roleOverride": "developer",
"metadata": null,
"createdAt": "2025-01-01T00:00:00Z"
}

The entity link stores a composite key "<alignProjectId>:<alignUserId>" as the alignEntityId.

POST /projects/:id/time-entries — Create a time entry

Called by the outbox worker when syncing a time_entry entity for the first time.

Request:

{
"userId": "<align-user-id>",
"workDate": "2025-01-15",
"minutes": 150,
"description": "Implemented authentication module",
"billable": true,
"externalRef": {
"provider": "illumera",
"externalType": "time_entry",
"externalId": "<illumera-time_entries.id>"
}
}

Response (Align TimeEntry shape):

{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"organizationId": "7b1e2c3d-0000-0000-0000-000000000001",
"projectId": "9a8b7c6d-0000-0000-0000-000000000002",
"userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"workDate": "2025-01-15",
"hours": "2.5",
"minutes": 150,
"description": "Implemented authentication module",
"billable": true,
"status": "draft",
"createdAt": "2025-01-15T12:00:00Z"
}

POST /time-entries/:id/submit — Submit a time entry

Transitions the Align entry from draft to submitted. Called when talent submits in Illumera.

Request body: none (empty POST).

Response: Updated TimeEntry with "status": "submitted".

PATCH /time-entries/:id — Update a time entry

Used to sync changes to minutes or description on a draft entry.

Request:

{ "minutes": 90, "description": "Revised estimate" }

Response: Updated TimeEntry.

DELETE /time-entries/:id — Delete a time entry

Called when talent deletes a draft entry in Illumera.

Response: HTTP 204 No Content.

POST /time-entries/:id/unlock — Unlock a time entry

Available in AlignGateway but not exposed in any Illumera UI route. Unlocking is Align-side only; Illumera handles the resulting time.unlocked webhook.

GET /projects/:id/time-summary — Time totals by status

Used by the nightly reconciliation worker to compare approved minutes.

Query params: status=approved (or omit for all statuses).

Response:

{
"projectId": "7b1e2c3d-0000-0000-0000-000000000001",
"totalMinutes": 3600,
"totalHours": "60.0",
"billableMinutes": 2400,
"byStatus": { "approved": 3600, "submitted": 0 },
"byUser": [{ "userId": "...", "totalMinutes": 3600 }]
}

GET /projects/:id/cost-summary — Approved cost totals

Used by the nightly reconciliation worker to compare invoiced cost against Align's approved cost.

Response:

{
"projectId": "7b1e2c3d-0000-0000-0000-000000000001",
"currency": "USD",
"plannedCost": "50000.00",
"actualCost": "32500.00",
"approvedCost": "31000.00",
"pendingCost": "1500.00"
}

Reconciliation flags discrepancies when |approvedCost * 100 - illumeraInvoicedCents| > 100 (i.e. > $1).

POST /projects/:id/wiki-pages — Create staffing wiki page

Called when a project is activated. Content is Markdown generated from the project's open slot list.

Request:

{
"title": "Illumera Staffing Plan",
"content": "# Illumera Staffing Plan — Federal Data Platform\n\n...",
"externalRef": {
"provider": "illumera",
"externalType": "wiki_page",
"externalId": "<illumera-projects.id>"
}
}

Response:

{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"projectId": "7b1e2c3d-0000-0000-0000-000000000001",
"title": "Illumera Staffing Plan",
"content": "# Illumera Staffing Plan — ...",
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-01-01T00:00:00Z"
}

POST /webhooks — Register inbound webhook

Called at connection time. The hmacSecret in the response is returned only once and must be encrypted and persisted immediately.

Request:

{
"destinationUrl": "https://api.illumera.us/api/webhooks/align",
"eventNames": ["time.logged", "time.submitted", "time.approved", "time.rejected", ...],
"description": "Illumera integration"
}

Response:

{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"destinationUrl": "https://api.illumera.us/api/webhooks/align",
"eventNames": ["time.approved", "time.rejected", ...],
"enabled": true,
"description": "Illumera integration",
"hmacSecret": "whsec_abc123...",
"createdAt": "2025-01-01T00:00:00Z"
}

Used by the reconciliation worker. Returns Align-side integration references so Illumera can confirm all align_entity_links rows still exist in Align.

Query params: page, pageSize (max 100).

Response: { data: [{ id, externalId, ... }], total: 42 }

Sync Triggers

Illumera eventHelper calledOutbox entity typeOperation
Project created/updatedsyncProject()projectupsert
Project activatedsyncStaffingWiki()wiki_pageupsert
Engagement acceptedsyncUser() + syncEngagementMember()user, project_memberupsert, upsert
Engagement endedsyncRemoveEngagementMember()project_memberdelete
Time entry created/updatedsyncTimeEntry()time_entryupsert
Time entry submittedsyncSubmitTimeEntry()time_entry_submitupsert
User profile updated (via Clerk webhook)syncUser()userupsert

All helpers first call hasActiveAlignConnection(tenantId, companyId) and return immediately (no DB write) if no active connection exists. The connection status is cached in memory for 60 seconds to minimize DB queries on high-traffic routes.

Outbox Worker

File: artifacts/api-server/src/workers/alignSyncWorker.ts

Poll interval: Registered in index.ts on a setInterval (typically 30 seconds).

Batch size: 10 items per tick.

Claim mechanism: Atomic UPDATE ... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED) RETURNING *. This makes multiple API server replicas safe — no two workers can claim the same row.

Lease recovery: Items stuck in processing for more than 10 minutes (e.g. after a crash) are reset to pending at the start of each tick.

Retry schedule (exponential backoff):

AttemptDelay
11 minute
25 minutes
330 minutes
42 hours
58 hours

After 5 failed attempts the item is marked failed (dead-letter). The lastErrorAt and lastErrorMessage columns on align_connections are updated. Platform admins can reset failed items via POST /admin/align/sync/retry-failed.

Rate limit handling: When AlignRateLimitError is thrown, the item is immediately re-queued as pending with nextRetryAt = now + retryAfterSeconds. This bypasses the standard backoff and respects the Retry-After header.

Entity ID resolution flow (example for time_entry):

  1. Worker reads payload._projectId (Align project ID if already known) or falls back to payload._localProjectId → looks up align_entity_links for the Align project ID.
  2. If no Align project ID is found, throws an error → item retries until the project is synced.
  3. Creates the time entry in Align; stores the returned Align ID in align_entity_links.

Inbound Webhook Handler

Route: POST /api/webhooks/align (in artifacts/api-server/src/routes/webhooks.ts)

Processing pipeline (in order):

  1. Extract headers — requires X-Align-Delivery-Id, X-Align-Event, X-Align-Timestamp, X-Align-Signature. Missing any returns HTTP 400.
  2. Parse body — reads rawBody Buffer; extracts organizationId from the JSON payload (at root level or data.organizationId).
  3. Lookup connection — queries align_connections by align_org_id = organizationId. Returns 404 if not found.
  4. HMAC-SHA256 verification — decrypts webhookSecret; computes "sha256=" + HMAC(rawBody, secret); compares with X-Align-Signature using constant-time safeCompare(). Returns 400 on mismatch.
  5. Deduplication — checks align_webhook_deliveries for an existing row with the same delivery_id. Returns HTTP 200 ({ received: true, deduplicated: true }) if found.
  6. Timestamp skew check — parses X-Align-Timestamp as Unix seconds; rejects if |now - timestamp| > 300s (5-minute window). Returns 400.
  7. Record delivery — inserts a row into align_webhook_deliveries with status = "received".
  8. Dispatch — calls handleAlignEvent(eventName, data, tenantId, companyId).
  9. Mark processed — updates delivery row to status = "processed" on success; status = "failed" with error on exception. Returns HTTP 500 on handler error so Align retries.

Webhook Event Catalog

Illumera subscribes to the following events at connection time. Events not listed are received and recorded but have no handler (no-op).

EventHandler behavior
time.approvedFinds linked time_entries row via align_entity_links; sets status = "approved", sync_status = "synced"
time.rejectedSets status = "rejected", rejection_reason = data.reason, sync_status = "synced"
time.lockedSets status = "locked", sync_status = "synced"
time.invoicedSets status = "invoiced", sync_status = "synced"
time.unlockedSets status = "draft", sync_status = "pending" (re-opens for editing)
time.deletedSoft-deletes the entry (deleted_at = now) only if current status is draft or submitted
time.loggedNo-op (originator is Align, not Illumera)
time.submittedNo-op (submission is initiated from Illumera)
time.updatedNo-op (updates flow from Illumera to Align, not the reverse)
project_member.addedUpserts align_entity_links using externalRef from payload (confirms member was synced)
project_member.updatedSame as project_member.added
project_member.removedNo-op (removal is initiated from Illumera)
user.invitedNo-op
user.linkedNo-op
user.updatedNo-op
wiki_page.createdUpserts align_entity_links for the wiki page
wiki_page.updatedNo-op
integration_link.createdNo-op
integration_link.updatedNo-op
invoice.paidNo-op
invoice.finalizedNo-op

Nightly Reconciliation

File: artifacts/api-server/src/workers/alignReconcileWorker.ts

Schedule: Nightly cron (registered in index.ts).

What it checks per active connection:

  1. Approved minutes per project: Calls GET /projects/:id/time-summary?status=approved from Align; compares totalMinutes against SUM(time_entries.minutes) WHERE status='approved' in Illumera. Writes a discrepancy row if values differ.

  2. Approved cost per project: Calls GET /projects/:id/cost-summary from Align; compares approvedCost * 100 (converted to cents) against the sum of invoices.amount_cents for engagements in that project. Discrepancies of ≤$1 are ignored to avoid floating-point noise.

  3. Entity link verification: Calls GET /integration-links (up to 100 entries) from Align; verifies that every row in align_entity_links has a corresponding Align-side integration reference. Missing entries are logged as entity_link.<type> discrepancies.

Manual resolution: Review align_reconciliation_log via the Admin sync dashboard. For minute/cost deltas, verify the specific entries in both systems and re-trigger a sync if needed. For missing entity links, the affected entity may need to be re-synced (use Retry Failed or trigger the relevant route operation again).

Secret Management

ALIGN_ENCRYPTION_KEY

All sensitive values (encrypted_api_key, webhook_secret) are encrypted with AES-256-GCM using a symmetric key from the environment:

ALIGN_ENCRYPTION_KEY=<64 hex chars = 32 bytes>

Generate with:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

This key must be the same across all API server replicas and must never be rotated without first decrypting and re-encrypting all stored values, or existing connections will become unreadable.

hmacSecret one-time retrieval

Align returns the hmacSecret only in the POST /webhooks response. It is never returned again. Illumera encrypts it immediately and stores it in align_connections.webhook_secret. If this value is lost, the webhook subscription must be deleted and recreated (which is what PATCH /align/connections does — it deletes the old subscription and creates a new one).

Key rotation procedure

  1. Generate a new ALIGN_ENCRYPTION_KEY.
  2. Read every encrypted_api_key and webhook_secret value from align_connections and decrypt with the old key.
  3. Re-encrypt each value with the new key and write it back.
  4. Deploy the new key.

There is no automated rotation tool; this procedure must be performed manually.

Rate Limiting

Align enforces 600 requests per minute per API key (sliding window). Responses include:

X-RateLimit-Limit: 600
X-RateLimit-Remaining: <n>
X-RateLimit-Reset: <unix_timestamp>
Retry-After: <seconds> (on 429 only)

The AlignGateway.request() method throws AlignRateLimitError on HTTP 429. The sync worker catches this and re-queues the item with nextRetryAt = now + retryAfterSeconds, bypassing the normal backoff schedule.

Bulk invite optimization: When multiple users need to be provisioned at once (e.g. a company with many accepted engagements on reconnect), bulkInviteUsers() should be preferred over individual inviteUser() calls to reduce request count. The current outbox worker calls inviteUser() per user; bulkInviteUsers() is available in AlignGateway for future optimization.

Align API Key Scopes

ScopeAllowed pathsCan Illumera use it?
OrganizationAll /api/v1/* pathsYes (required)
Project/projects/:scopedProjectId/* and /me onlyNo — rejected at connect time

Project-scoped keys cannot create new projects, invite users outside a project, or register webhooks. The connect route (POST /align/connections) checks me.scope === "organization" and returns HTTP 400 with a clear message if a project-scoped key is submitted.

Members with the developer, tech_lead, devops_engineer, or manager role in Align can only mint project-scoped keys — only organization administrators can mint organization-scoped keys.

Align User Role Mapping

Illumera roleAlign role assigned
person (talent / contractor)developer
Company hiring manager (future)manager

The role developer is used because it most closely maps to the contractor relationship in a government contracting context. This is hardcoded in syncUser() (role: "developer").

Known Limitations

LimitationDetail
Unlock not exposed in UIunlockTimeEntry() is implemented in AlignGateway but there is no Illumera UI route to trigger it. Unlocking is currently Align-side only; Illumera handles the time.unlocked webhook by reverting status to draft.
Historical data not migratedProjects, users, and time entries that existed before the connection was established are not backfilled. Only changes made after connecting are synced.
Project-scoped keys rejectedBy design. Illumera requires organization-scoped keys.
Bulk invite not used in outboxThe outbox worker calls inviteUser() per user rather than bulkInviteUsers(). Under high load this can accelerate rate-limit consumption.
Reconciliation page sizeIntegration link verification fetches at most 100 entries from Align's /integration-links. Organizations with more than 100 linked entities may have incomplete verification.

Environment Variables

VariableRequiredDefaultDescription
ALIGN_ENCRYPTION_KEYYes64 hex chars (32 bytes). Used to encrypt/decrypt API keys and HMAC secrets at rest.
ALIGN_BASE_URLNohttps://app.alignsoft.us/api/v1Override for staging/testing against a non-production Align instance.
ALIGN_WEBHOOK_BASE_URLNoDerived from getApiBaseUrl()Override the public base URL Illumera registers with Align as the webhook destination. Set this when the API server's public URL cannot be auto-detected (e.g. in certain Railway configurations).

Testing Guide

Unit testing the outbox worker

The sync worker (alignSyncWorker.ts) and the gateway (alignGateway.ts) are decoupled by the outbox table, making them independently testable.

Mock the gateway in worker tests:

// In your test file
import { processAlignSyncQueue } from "../workers/alignSyncWorker";
import { AlignGateway } from "../lib/alignGateway";

vi.mock("../lib/alignGateway", () => ({
AlignGateway: vi.fn().mockImplementation(() => ({
createProject: vi.fn().mockResolvedValue({ id: "align-proj-1" }),
inviteUser: vi.fn().mockResolvedValue({ userId: "align-user-1", created: true }),
createTimeEntry: vi.fn().mockResolvedValue({ id: "align-te-1" }),
submitTimeEntry: vi.fn().mockResolvedValue({ id: "align-te-1", status: "submitted" }),
})),
}));

Seed the outbox table and run the worker:

await db.insert(alignSyncOutboxTable).values({
id: randomUUID(),
tenantId: "tenant-1",
companyId: "company-1",
entityType: "project",
localId: "project-uuid",
operation: "upsert",
payload: { name: "Test Project", timeBillingEnabled: true },
status: "pending",
attempts: 0,
nextRetryAt: new Date(),
});

await processAlignSyncQueue();

const [row] = await db.select().from(alignSyncOutboxTable).where(eq(alignSyncOutboxTable.localId, "project-uuid"));
expect(row.status).toBe("done");

// Verify the entity link was created
const [link] = await db.select().from(alignEntityLinksTable).where(eq(alignEntityLinksTable.externalId, "project-uuid"));
expect(link.alignEntityId).toBe("align-proj-1");

Integration testing the webhook handler

The inbound webhook handler requires a valid HMAC-SHA256 signature. Build it in your test helper:

import { createHmac } from "crypto";

function buildAlignWebhookRequest(
secret: string,
eventName: string,
body: object,
deliveryId = randomUUID(),
): { headers: Record<string, string>; rawBody: Buffer } {
const rawBody = Buffer.from(JSON.stringify(body));
const timestamp = Math.floor(Date.now() / 1000);
const sig = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
return {
rawBody,
headers: {
"x-align-delivery-id": deliveryId,
"x-align-event": eventName,
"x-align-timestamp": String(timestamp),
"x-align-signature": sig,
},
};
}

Test the time.approved handler:

// Seed a connection with a known hmacSecret and an entity link
const hmacSecret = "test-secret-32-bytes-long-xxxxxx";
const encryptedSecret = encrypt(hmacSecret);
// ... insert alignConnectionsTable row with webhookSecret = encryptedSecret, alignOrgId = "org-1"
// ... insert alignEntityLinksTable row: externalType=time_entry, externalId=<localTeId>, alignEntityId="align-te-1"
// ... insert timeEntriesTable row: id=<localTeId>, status="submitted"

const { headers, rawBody } = buildAlignWebhookRequest(
hmacSecret,
"time.approved",
{ organizationId: "org-1", data: { id: "align-te-1" } },
);

const res = await supertest(app)
.post("/api/webhooks/align")
.set(headers)
.send(JSON.parse(rawBody.toString()));

expect(res.status).toBe(200);
expect(res.body.received).toBe(true);

const [te] = await db.select().from(timeEntriesTable).where(eq(timeEntriesTable.id, localTeId));
expect(te.status).toBe("approved");

Testing idempotency

Verify that re-sending the same X-Align-Delivery-Id returns HTTP 200 with deduplicated: true and does not double-process:

const deliveryId = randomUUID();
const req1 = buildAlignWebhookRequest(hmacSecret, "time.approved", body, deliveryId);
const req2 = buildAlignWebhookRequest(hmacSecret, "time.approved", body, deliveryId);

await post(req1); // first delivery — processes
await post(req2); // duplicate — skips handler, returns { received: true, deduplicated: true }

Testing the reconciliation worker

import { runAlignReconciliation } from "../workers/alignReconcileWorker";

// Mock AlignGateway to return a different totalMinutes than the DB
vi.mock("../lib/alignGateway", () => ({
AlignGateway: vi.fn().mockImplementation(() => ({
getTimeSummary: vi.fn().mockResolvedValue({ totalMinutes: 999, totalHours: "16.65" }),
getCostSummary: vi.fn().mockResolvedValue({ approvedCost: 0 }),
listIntegrationLinks: vi.fn().mockResolvedValue({ data: [] }),
})),
}));

await runAlignReconciliation();

const discrepancies = await db.select().from(alignReconciliationLogTable)
.where(eq(alignReconciliationLogTable.field, "approved_minutes"));
expect(discrepancies.length).toBeGreaterThan(0);
expect(discrepancies[0].alignValue).toBe("999");

End-to-end testing with a real Align sandbox

For full E2E coverage against a real Align organization:

  1. Set ALIGN_BASE_URL to a sandbox Align instance (or use the production Align API with a dedicated test organization).
  2. Set ALIGN_ENCRYPTION_KEY to a valid 64-hex-char key.
  3. Call POST /api/align/connections with a real organization-scoped key and a test company ID.
  4. Create a project via POST /api/projects — observe it appear in Align within ~30 seconds.
  5. Accept an engagement — observe the user provisioned and added as a project member in Align.
  6. Log and submit a time entry — observe it pushed to Align and submitted.
  7. Approve the entry in Align — observe the time.approved webhook arrive and the Illumera status update (requires the API server's /api/webhooks/align endpoint to be publicly accessible from Align).
Webhook reachability in local dev

Align pushes webhooks to the registered URL. In local development the API server is not publicly accessible. Use ngrok or a similar tunneling tool to expose localhost:PORT/api/webhooks/align to the internet, then set ALIGN_WEBHOOK_BASE_URL to your tunnel URL before connecting.