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
- Process webhooks as they arrive, for the events Align delivers.
- Poll the list endpoints on a schedule, compare with your local state, and upsert differences.
- Check your ID mappings with
GET /integration-linksto confirm that the links between your IDs and Align's are consistent.
Endpoints to poll
| Resource | Endpoint | Suggested cadence |
|---|---|---|
| Users | GET /api/v1/users | Nightly |
| Project members | GET /api/v1/projects/{id}/members | Nightly per active project |
| Time entries | GET /api/v1/projects/{id}/time-entries | Hourly: time changes often and has no delivered webhook events |
| Wiki pages | GET /api/v1/projects/{id}/wiki-pages | Nightly |
| Release evidence | GET /api/v1/releases/{id}/evidence | After a release.released event |
| Integration links | GET /api/v1/integration-links | Nightly, and after provisioning calls |
GET /users and GET /integration-links need an organisation-scoped key.
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
}
| Field | Type | Description |
|---|---|---|
data | array | Items on this page |
page | integer | Page number, starting at 1 |
pageSize | integer | Items per page: default 25, maximum 100 |
total | integer | Matching 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;
}
Checking ID mappings with GET /integration-links
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.
Listing links
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 parameter | Description |
|---|---|
provider | The provider name you used, for example illumera |
externalType | The record type in your system, for example time_log or member |
alignEntityType | The Align record type, for example time_entry, user or invitation |
There is no filter by externalId; filter the results yourself.
What to compare
- 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.
- Align has a link that you do not. You missed the creation. Fetch the Align record and store it.
- The
alignEntityIddiffers. 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);
}
}