Skip to main content

Reconciliation

Webhooks tell you about changes quickly, but a delivery can be missed: your endpoint was down for longer than Align retries (about a minute), or it returned a 4xx. Some data, such as time entries, users and project members, has no webhook events delivered today at all (see Webhooks). A regular reconciliation pass keeps your copy in step with Align.

Strategy​

  1. Process webhooks as they arrive, for the events Align delivers.
  2. Poll the list endpoints on a schedule, compare with your local state, and upsert differences.
  3. Check your ID mappings with GET /integration-links to confirm that the links between your IDs and Align's are consistent.

Endpoints to poll​

ResourceEndpointSuggested cadence
UsersGET /api/v1/usersNightly
Project membersGET /api/v1/projects/{id}/membersNightly per active project
Time entriesGET /api/v1/projects/{id}/time-entriesHourly: time changes often and has no delivered webhook events
Wiki pagesGET /api/v1/projects/{id}/wiki-pagesNightly
Release evidenceGET /api/v1/releases/{id}/evidenceAfter a release.released event
Integration linksGET /api/v1/integration-linksNightly, and after provisioning calls

GET /users and GET /integration-links need an organisation-scoped key.

tip

For time entries, use startDate and endDate to poll a rolling window (for example the last 7 days) rather than the full history.

Pagination​

Paginated list endpoints use offset pagination with page and pageSize query parameters:

GET /api/v1/projects/{id}/time-entries?page=1&pageSize=100

Response shape:

{
"data": [ /* items */ ],
"page": 1,
"pageSize": 100,
"total": 347
}
FieldTypeDescription
dataarrayItems on this page
pageintegerPage number, starting at 1
pageSizeintegerItems per page: default 25, maximum 100
totalintegerMatching items across all pages

A few list endpoints, including GET /projects/{id}/wiki-pages, GET /webhooks, GET /search and GET /projects/{id}/agreements, return every item in one response with the same shape (page is 1 and pageSize equals the number of items). GET /search returns at most 50 items and can't be paged. The loop below handles both.

To fetch every page:

async function fetchAll(url, apiKey) {
const results = [];
let page = 1;
const pageSize = 100;

while (true) {
const u = new URL(url);
u.searchParams.set("page", String(page));
u.searchParams.set("pageSize", String(pageSize));
const res = await fetch(u, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) throw new Error(`Align API ${res.status}: ${await res.text()}`);
const body = await res.json();
results.push(...body.data);
if (body.data.length === 0 || results.length >= body.total) break;
page++;
}
return results;
}

When you create Align records through the API with an externalRef (users, time entries) or through POST /integration/time-entries, Align stores an integration link from your ID to the Align ID. GET /integration-links lists them.

Requirements: an organisation-scoped key with read:time_entry.

GET /api/v1/integration-links?provider=illumera&externalType=time_log&page=1&pageSize=100
Authorization: Bearer <api-key>

Response (abbreviated):

{
"data": [
{
"id": "link-uuid-...",
"provider": "illumera",
"externalType": "time_log",
"externalId": "illumera-log-9876",
"alignEntityType": "time_entry",
"alignEntityId": "te-uuid-...",
"projectId": "proj-uuid-...",
"organizationId": "org-uuid-...",
"createdAt": "2026-10-13T09:00:00.000Z"
}
],
"page": 1,
"pageSize": 100,
"total": 1
}

Filters​

Query parameterDescription
providerThe provider name you used, for example illumera
externalTypeThe record type in your system, for example time_log or member
alignEntityTypeThe Align record type, for example time_entry, user or invitation

There is no filter by externalId; filter the results yourself.

What to compare​

  1. You have a link that Align does not. The Align record may have been deleted. Deleting a time entry through the API also deletes its links. Flag or remove the link on your side.
  2. Align has a link that you do not. You missed the creation. Fetch the Align record and store it.
  3. The alignEntityId differs. Update your table to match Align.

To remove a link you no longer want, call DELETE /api/v1/integration-links/{id} (needs delete:integration_link).

Sample reconciliation script (Node.js)​

const BASE = "https://app.alignsoft.us/api/v1";
const KEY = process.env.ALIGN_KEY;

async function reconcileTimeEntries(projectId, localDb) {
const sevenDaysAgo = new Date(Date.now() - 7 * 86400_000)
.toISOString()
.slice(0, 10);

const alignEntries = await fetchAll(
`${BASE}/projects/${projectId}/time-entries?startDate=${sevenDaysAgo}`,
KEY,
);

for (const entry of alignEntries) {
const local = await localDb.getTimeEntry(entry.id);
if (!local || local.status !== entry.status || local.hours !== entry.hours) {
await localDb.upsertTimeEntry(entry);
}
}
}

async function reconcileIntegrationLinks(provider, externalType, localDb) {
const alignLinks = await fetchAll(
`${BASE}/integration-links?provider=${provider}&externalType=${externalType}`,
KEY,
);

const alignMap = new Map(alignLinks.map((l) => [l.externalId, l]));
const localLinks = await localDb.getLinks(provider, externalType);
const localMap = new Map(localLinks.map((l) => [l.externalId, l]));

// In your database but not in Align: the Align record was probably deleted.
for (const [externalId, local] of localMap) {
if (!alignMap.has(externalId)) await localDb.flagLinkMissing(local.id);
}

// In Align but not in your database: you missed the creation.
for (const [externalId, align] of alignMap) {
if (!localMap.has(externalId)) await localDb.insertLink(align);
}
}