Syncing from another system

Sync assignments, time offs, actuals, and reference data from another system without exceeding the rate limits

Overview

Use this guide when another system, such as a professional services automation (PSA), enterprise resource planning (ERP), HR, or timesheet tool, is the source of truth for data in Runn and you want Runn to match it on a schedule.

The approach is the same for every resource: read what Runn already holds, compare it with your source, and write only the differences. Deleting everything and creating it again, or sending every record on every run, uses far more requests. Each write counts towards the rate limits, and for some resources a duplicate create fails with 400.

For example, take 10,000 assignments in a single project, of which 2% change between runs:

ApproachRequests per runApplies changes?
Delete everything, then create everythingabout 20,000Yes
Create everything and ignore the 400 errorsabout 10,000No. A changed assignment overlaps the old one and is rejected, so only new assignments are added
Read, compare, and write the differences50 reads (10,000 at 200 per page), 200 deletes, and 200 createsYes

Reads grow with the number of projects you fetch separately, but stay far below the number of writes.

How you compare depends on how Runn stores each resource:

ResourceHow Runn handles a writeHow to compare
Reference data such as people, clients, and skillsRejects a duplicate name or emailLook up, then create what is missing
ActualsOverwrites the actual for the same date, person, project, role, and workstreamCompare per day. No deletes needed
Time offsMerges overlapping leave into one recordCompare the days each person is on leave
AssignmentsRejects an overlapping assignment. There is no updateCompare record by record
📘

Guides

This guide covers how to compare and write. For the rules on creating each resource, see the Actuals, Time offs, and Assignments guides.

Principles

Decide what the sync owns

Before the sync deletes anything, decide which records belong to it. This is the sync's scope. Assignments, actuals, and time offs don't carry an external ID, so their scope comes from the requests you make:

  • Which records. For example, only the projects or people that your source system manages. People, projects, clients, and roles can store your system's IDs in references, and GET /people and GET /projects can filter by externalId, which makes these easy to identify. Records that people create in the Runn app outside that scope stay untouched.
  • Which dates. If your source system doesn't change the past, sync from the start of the current week onwards. This date range is the sync's date window. You set it with the date filters on each list endpoint, such as startDate and endDate on GET /assignments.
⚠️

Edits in Runn are overwritten

Inside its scope, the sync treats every record as its own. If people also edit those records in the Runn app, the next run reverts their changes to match your source.

Build the records Runn should hold

Don't compare your source records directly with Runn. First convert them into the records Runn should hold, following the rules for creating each resource. For example, trim an assignment that starts on a Saturday to the following Monday, and send actuals as one record per day.

The conversion must be deterministic: the same source data must produce exactly the same records on every run. Otherwise the comparison finds differences that aren't there, and the sync rewrites the same records on every run.

Read once per run

Fetch each collection you compare once per run. Set limit to the maximum the endpoint allows (200 for most endpoints, 500 for GET /actuals) and follow nextCursor until it is null. See Pagination.

Prefer one request that covers your whole date window over many small ones. A date filter returns every record that overlaps the range, so a record that spans two ranges appears in both responses. If you do split the window, remove duplicates by id before you compare.

Normalise both sides

Convert both sides into the same representation before you compare:

  • IDs as integers.
  • Dates as YYYY-MM-DD strings.
  • Minutes as integers, for example Math.round(hours * 60).
  • Empty values as either null or "". Choose one and use it on both sides.

A field that never compares equal makes the sync rewrite the same records on every run.

Delete before you create

Several resources reject a new record that overlaps an existing one. Send deletes first, then creates.

Pace your requests

Stay within the rate limits. When you receive 429 Too Many Requests, wait for the number of seconds in the retry-after header, then retry the same request.

Keep a local copy (optional)

The simplest sync stores no state of its own: Runn holds the current state, and each run compares against it.

For large accounts, you can also keep a local copy of what you last wrote to Runn, with the Runn id of each record. Each run then compares your source against the local copy, and reads from Runn only to catch changes made there, using the modifiedAfter filter. modifiedAfter doesn't return deleted records, so run a full comparison against Runn from time to time to catch records deleted in Runn.

Reference data

People, clients, skills, tags, workstreams, and custom field options are usually created once and then referenced by ID. Runn rejects a second record with the same name, or a second person with the same email, with 400 and an "already exists" message.

At the start of each run, fetch the records you reference, map them to their Runn id by external ID where the resource has references, otherwise by name or email, and create only the ones that are missing. Don't use a create request to find out whether something exists.

Actuals

An actual is the time one person spent on one project, in one role and workstream, on one day. If your source stores time entries or weekly timesheets, add them up into one record per person, project, role, workstream, and day before you compare.

POST /actuals and POST /actuals/bulk overwrite the existing actual for the same date, person, project, role, and workstream. Sending the same actual twice changes nothing, so a sync never needs to delete an actual to change it.

  1. Fetch the actuals for your date window with GET /actuals, using minDate and maxDate. End the window today: Runn rejects actuals in the future unless the account allows them.
  2. Compare them per day with your source, on billableMinutes and nonbillableMinutes.
  3. Send the days that differ, in batches of up to 100 with the bulk endpoint. If you keep a local copy or your source tracks changes, you can skip the read and send only what changed since the last run.

To clear an actual, send it with billableMinutes and nonbillableMinutes set to 0. Runn keeps the actual with 0 minutes, and GET /actuals still returns it. When you compare, treat an actual with 0 minutes the same as a missing one, or the sync sends it again on every run.

Send workstreamId the same way every time. Without workstreamId, an actual matches an existing actual on any workstream. With "workstreamId": null, it matches only an actual without a workstream. See Actuals.

Time offs

Sync leave only. Public holidays are managed through the account's holiday settings, and Runn generates rostered days off from each person's contract, so don't sync either as leave.

Runn merges leave that overlaps existing leave with the same minutesPerDay into one record. The records in Runn therefore don't match your source records one to one, and a record-by-record comparison finds differences on every run.

Like assignments, leave must start and end on a weekday. Runn also stores leave of at least the person's contracted minutes per day as a full day, with minutesPerDay set to null. Send full-day leave without minutesPerDay.

Compare the days each person is on leave instead:

  1. Fetch the leave for your date window with GET /time-offs/leave.
  2. For each person, list every weekday of leave with its minutesPerDay, once from your source and once from Runn. Use null for a full day on both sides, including source leave of at least the person's contracted minutes.
  3. If a person's lists match, send nothing for that person.
  4. If your source only adds days, create the new leave with POST /time-offs/leave/bulk. Runn merges it with any overlapping leave.
  5. If your source removes or changes any days, delete every Runn record that covers a removed or changed day with DELETE /time-offs/leave/bulk. Then create your source leave for the full date range of each deleted record, including any days your source adds. There is no update endpoint for leave, and a merged record can cover days that didn't change, so delete it whole and create it again.

Assignments

Runn rejects an assignment that overlaps an existing one for the same person, project, role, and workstream (and phase, on accounts with phase rescheduling enabled). The check ignores minutesPerDay, note, and isBillable, and there is no update endpoint, so a changed assignment is a delete followed by a create.

  1. Build the assignments Runn should hold from your source, following the rules in Assignments. Start and end dates must be weekdays. An assignment can span a weekend: Friday to Monday is one assignment, and the weekend isn't scheduled.

  2. For each project in scope, fetch the assignments for your date window with GET /assignments.

  3. Compare each assignment on these fields after normalising them:

    FieldNormalise to
    personId, projectId, roleIdInteger IDs
    workstreamId, phaseIdInteger ID, or null when unset
    startDate, endDateYYYY-MM-DD strings
    minutesPerDayInteger
    isBillableBoolean. Runn defaults to true
    noteString, or null

    Don't compare isNonWorkingDay unless you set it explicitly. Runn sets it for weekends and public holidays when you omit it.

  4. Delete the assignments that are in Runn but not in your source with DELETE /assignments/{assignmentId}.

  5. Create the assignments that are in your source but not in Runn with POST /assignments.

// normalise() converts a record into the representation in the table above.
const key = (a) => {
  const n = normalise(a)
  return [
    n.personId, n.projectId, n.roleId, n.workstreamId, n.phaseId,
    n.startDate, n.endDate, n.minutesPerDay, n.isBillable, n.note,
  ].join("|")
}

const current = new Map(runnAssignments.map((a) => [key(a), a]))
const desired = new Map(sourceAssignments.map((a) => [key(a), a]))

const toDelete = [...current].filter(([k]) => !desired.has(k)).map(([, a]) => a)
const toCreate = [...desired].filter(([k]) => !current.has(k)).map(([, a]) => a)

join turns null and undefined into empty strings, so normalise empty values first. Identical source assignments share a key and are created once.

For example, if an assignment moves from Thursday–Friday to Friday–Monday, the comparison deletes the old assignment and creates the new one. Because the deletes go first, the new assignment doesn't collide with the old one on Friday.

Compare against source assignments that overlap the date window, using their full dates. Don't trim them to the window: an assignment that started last week is still one assignment in Runn.

Troubleshooting

SymptomLikely cause
Creates fail with 400 "Assignment exists for the date range for this person, project, role and workstream"An overlapping assignment is still in Runn. Either the sync sends creates before deletes, the overlapping assignment is outside what the sync fetched (another date range or project), two assignments in your source overlap each other, or someone changed Runn after the sync fetched it.
Leave creates fail with 400 "Cannot overlap with another time off with different minutes per day."The old leave is still there, or one side sends contracted minutes where the other has a full day. Delete the old leave first, and send full-day leave without minutesPerDay.
Creates fail with 400 "already exists"The sync creates reference data without looking it up first.
Every run deletes and recreates the same recordsThe conversion from your source isn't deterministic, a field that Runn doesn't store or returns in a different form is compared, or leave is compared record by record instead of by day.
Records made in the Runn app disappear after a syncThey fall inside the sync's scope. Narrow the records or dates that the sync owns.
Runs take hours or receive 429 responsesThe sync reads or writes more than it needs. Check that it reads each collection once per run and writes only the differences.


Did this page help you?