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.
| Column | Type | Nullable | Description |
|---|---|---|---|
id | text PK | No | Random UUID |
tenant_id | text FK→tenants | No | Owning tenant |
company_id | text FK→company_profiles | No | Owning company |
encrypted_api_key | text | No | AES-256-GCM encrypted Align API key |
key_last_four | text | No | Last 4 chars of the key (display only) |
align_org_id | text | No | Align organization UUID (from /me) |
align_key_scope | text | No | "organization" (project-scoped keys are rejected) |
webhook_subscription_id | text | Yes | Align webhook subscription ID |
webhook_secret | text | Yes | AES-256-GCM encrypted HMAC secret (one-time from Align) |
webhook_url | text | Yes | Registered webhook endpoint URL |
status | enum | No | active / error / disconnected |
last_sync_at | timestamptz | Yes | When the most recent outbox item succeeded |
last_error_at | timestamptz | Yes | When the most recent failure was recorded |
last_error_message | text | Yes | Truncated error from the last failed item |
created_at | timestamptz | No | Row creation timestamp |
updated_at | timestamptz | No | Auto-updated on every write |
align_entity_links
Maps an Illumera entity (by UUID) to an Align entity (by Align ID). Written by the sync worker after each successful create call.
| Column | Type | Nullable | Description |
|---|---|---|---|
id | text PK | No | Random UUID |
tenant_id | text FK→tenants | No | Owning tenant |
company_id | text FK→company_profiles | No | Owning company |
provider | text | No | Always "illumera" |
external_type | text | No | Illumera entity type: project, user, project_member, time_entry, wiki_page |
external_id | text | No | Illumera UUID for this entity |
align_entity_type | text | No | Align entity type |
align_entity_id | text | No | Align entity ID returned by the API |
created_at | timestamptz | No | |
updated_at | timestamptz | No | Auto-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.
| Column | Type | Nullable | Description |
|---|---|---|---|
id | text PK | No | Random UUID |
tenant_id | text FK→tenants | No | |
company_id | text FK→company_profiles | No | |
project_id | text FK→projects | No | |
engagement_id | text FK→engagements | No | |
person_id | text FK→person_profiles | No | The talent who logged hours |
work_date | text | No | ISO date string (YYYY-MM-DD) |
minutes | integer | No | Duration in minutes |
notes | text | Yes | Optional description / notes |
status | enum | No | draft / submitted / approved / rejected / locked / invoiced |
rejection_reason | text | Yes | Populated by time.rejected webhook |
source | enum | No | illumera (created here) or align (originated in Align) |
sync_status | enum | No | pending / synced / error |
sync_error | text | Yes | Last sync error for this entry |
is_adjustment | boolean | No | true for correction entries |
adjusts_entry_id | text | Yes | ID of the entry this corrects |
deleted_at | timestamptz | Yes | Soft-delete timestamp |
created_at | timestamptz | No | |
updated_at | timestamptz | No | Auto-updated |
align_webhook_deliveries
Audit log of every inbound Align webhook delivery.
| Column | Type | Nullable | Description |
|---|---|---|---|
id | text PK | No | Internal UUID |
delivery_id | text UNIQUE | No | X-Align-Delivery-Id header value (used for deduplication) |
event_name | text | No | e.g. time.approved |
tenant_id | text | Yes | Resolved from align_org_id lookup |
company_id | text | Yes | Resolved from connection lookup |
payload | jsonb | Yes | Full request body |
status | enum | No | received / processed / failed |
received_at | timestamptz | No | |
processed_at | timestamptz | Yes | Set when handler completes |
error | text | Yes | Handler error message |
align_sync_outbox
Durable outbox queue for all outbound sync operations. The worker claims rows atomically using FOR UPDATE SKIP LOCKED.
| Column | Type | Nullable | Description |
|---|---|---|---|
id | text PK | No | Random UUID |
tenant_id | text FK→tenants | No | |
company_id | text FK→company_profiles | No | |
entity_type | text | No | project, user, project_member, time_entry, time_entry_submit, wiki_page |
local_id | text | No | Illumera entity UUID |
operation | enum | No | upsert / delete |
payload | jsonb | Yes | Typed payload (see Sync Triggers) |
status | enum | No | pending / processing / done / failed |
attempts | integer | No | Number of delivery attempts |
last_attempt_at | timestamptz | Yes | |
next_retry_at | timestamptz | No | Earliest time the worker should attempt this item |
error | text | Yes | Last error from the worker |
created_at | timestamptz | No |
align_reconciliation_log
Records discrepancies found by the nightly reconciliation job.
| Column | Type | Nullable | Description |
|---|---|---|---|
id | text PK | No | Random UUID |
tenant_id | text FK→tenants | No | |
project_id | text FK→projects | Yes | The project where the discrepancy was found |
field | text | No | What was compared: approved_minutes, approved_cost_cents, entity_link.<type> |
illumera_value | text | Yes | Illumera's recorded value |
align_value | text | Yes | Align's returned value |
detected_at | timestamptz | No | Timestamp of the reconciliation run |
Entity Mapping Table
| Illumera entity | Align entity | externalType used |
|---|---|---|
projects.id | Align project | project |
users.id | Align user | user |
engagements.id (as project member) | Align project member | project_member |
time_entries.id | Align time entry | time_entry |
projects.id (as wiki page) | Align wiki page | wiki_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"
}
GET /integration-links — Verify entity links
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 event | Helper called | Outbox entity type | Operation |
|---|---|---|---|
| Project created/updated | syncProject() | project | upsert |
| Project activated | syncStaffingWiki() | wiki_page | upsert |
| Engagement accepted | syncUser() + syncEngagementMember() | user, project_member | upsert, upsert |
| Engagement ended | syncRemoveEngagementMember() | project_member | delete |
| Time entry created/updated | syncTimeEntry() | time_entry | upsert |
| Time entry submitted | syncSubmitTimeEntry() | time_entry_submit | upsert |
| User profile updated (via Clerk webhook) | syncUser() | user | upsert |
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):
| Attempt | Delay |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 8 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):
- Worker reads
payload._projectId(Align project ID if already known) or falls back topayload._localProjectId→ looks upalign_entity_linksfor the Align project ID. - If no Align project ID is found, throws an error → item retries until the project is synced.
- 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):
- Extract headers — requires
X-Align-Delivery-Id,X-Align-Event,X-Align-Timestamp,X-Align-Signature. Missing any returns HTTP 400. - Parse body — reads
rawBodyBuffer; extractsorganizationIdfrom the JSON payload (at root level ordata.organizationId). - Lookup connection — queries
align_connectionsbyalign_org_id = organizationId. Returns 404 if not found. - HMAC-SHA256 verification — decrypts
webhookSecret; computes"sha256=" + HMAC(rawBody, secret); compares withX-Align-Signatureusing constant-timesafeCompare(). Returns 400 on mismatch. - Deduplication — checks
align_webhook_deliveriesfor an existing row with the samedelivery_id. Returns HTTP 200 ({ received: true, deduplicated: true }) if found. - Timestamp skew check — parses
X-Align-Timestampas Unix seconds; rejects if|now - timestamp| > 300s(5-minute window). Returns 400. - Record delivery — inserts a row into
align_webhook_deliverieswithstatus = "received". - Dispatch — calls
handleAlignEvent(eventName, data, tenantId, companyId). - 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).
| Event | Handler behavior |
|---|---|
time.approved | Finds linked time_entries row via align_entity_links; sets status = "approved", sync_status = "synced" |
time.rejected | Sets status = "rejected", rejection_reason = data.reason, sync_status = "synced" |
time.locked | Sets status = "locked", sync_status = "synced" |
time.invoiced | Sets status = "invoiced", sync_status = "synced" |
time.unlocked | Sets status = "draft", sync_status = "pending" (re-opens for editing) |
time.deleted | Soft-deletes the entry (deleted_at = now) only if current status is draft or submitted |
time.logged | No-op (originator is Align, not Illumera) |
time.submitted | No-op (submission is initiated from Illumera) |
time.updated | No-op (updates flow from Illumera to Align, not the reverse) |
project_member.added | Upserts align_entity_links using externalRef from payload (confirms member was synced) |
project_member.updated | Same as project_member.added |
project_member.removed | No-op (removal is initiated from Illumera) |
user.invited | No-op |
user.linked | No-op |
user.updated | No-op |
wiki_page.created | Upserts align_entity_links for the wiki page |
wiki_page.updated | No-op |
integration_link.created | No-op |
integration_link.updated | No-op |
invoice.paid | No-op |
invoice.finalized | No-op |
Nightly Reconciliation
File: artifacts/api-server/src/workers/alignReconcileWorker.ts
Schedule: Nightly cron (registered in index.ts).
What it checks per active connection:
-
Approved minutes per project: Calls
GET /projects/:id/time-summary?status=approvedfrom Align; comparestotalMinutesagainstSUM(time_entries.minutes) WHERE status='approved'in Illumera. Writes a discrepancy row if values differ. -
Approved cost per project: Calls
GET /projects/:id/cost-summaryfrom Align; comparesapprovedCost * 100(converted to cents) against the sum ofinvoices.amount_centsfor engagements in that project. Discrepancies of ≤$1 are ignored to avoid floating-point noise. -
Entity link verification: Calls
GET /integration-links(up to 100 entries) from Align; verifies that every row inalign_entity_linkshas a corresponding Align-side integration reference. Missing entries are logged asentity_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
- Generate a new
ALIGN_ENCRYPTION_KEY. - Read every
encrypted_api_keyandwebhook_secretvalue fromalign_connectionsand decrypt with the old key. - Re-encrypt each value with the new key and write it back.
- 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
| Scope | Allowed paths | Can Illumera use it? |
|---|---|---|
| Organization | All /api/v1/* paths | Yes (required) |
| Project | /projects/:scopedProjectId/* and /me only | No — 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 role | Align 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
| Limitation | Detail |
|---|---|
| Unlock not exposed in UI | unlockTimeEntry() 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 migrated | Projects, users, and time entries that existed before the connection was established are not backfilled. Only changes made after connecting are synced. |
| Project-scoped keys rejected | By design. Illumera requires organization-scoped keys. |
| Bulk invite not used in outbox | The outbox worker calls inviteUser() per user rather than bulkInviteUsers(). Under high load this can accelerate rate-limit consumption. |
| Reconciliation page size | Integration 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
| Variable | Required | Default | Description |
|---|---|---|---|
ALIGN_ENCRYPTION_KEY | Yes | — | 64 hex chars (32 bytes). Used to encrypt/decrypt API keys and HMAC secrets at rest. |
ALIGN_BASE_URL | No | https://app.alignsoft.us/api/v1 | Override for staging/testing against a non-production Align instance. |
ALIGN_WEBHOOK_BASE_URL | No | Derived 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:
- Set
ALIGN_BASE_URLto a sandbox Align instance (or use the production Align API with a dedicated test organization). - Set
ALIGN_ENCRYPTION_KEYto a valid 64-hex-char key. - Call
POST /api/align/connectionswith a real organization-scoped key and a test company ID. - Create a project via
POST /api/projects— observe it appear in Align within ~30 seconds. - Accept an engagement — observe the user provisioned and added as a project member in Align.
- Log and submit a time entry — observe it pushed to Align and submitted.
- Approve the entry in Align — observe the
time.approvedwebhook arrive and the Illumera status update (requires the API server's/api/webhooks/alignendpoint to be publicly accessible from Align).
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.