Export Harvest to monday.com: CSV Import, Zapier, or an API Sync
← All posts
GuideNov 2, 2026· 8 min read

Export Harvest to monday.com: CSV Import, Zapier, or an API Sync

To export Harvest to monday.com, download a time report from Harvest as CSV or Excel and bring it into a board with monday’s Import items option, mapping each Harvest column to a board column. That works for a closed month or a one-off project review. If hours need to keep flowing onto the board, connect the two apps with Zapier’s Harvest New Time Entry trigger, or run a short script that reads time entries from the Harvest API and creates monday items.

One thing surprises people first: the Harvest integrations you find inside monday.com mostly point the other way. They help you track time to Harvest from monday items. They do not put Harvest hours on your board. This guide explains that difference, then walks through the three routes that do move data from Harvest into monday.com, and ends with timesheets that never lived in a Harvest account at all.

What the built-in Harvest and monday.com integrations do

There are two official connections, and neither is an export.

Harvest’s monday.com integration runs through the Harvest browser extension for Chrome, Firefox, or Edge. Open an item from a Table view and a Start timer button appears at the top. The timer writes a time entry into Harvest with the item name in the notes and a link back to the item. Harvest’s help page is direct about the limits: projects and tasks do not sync between the two apps, the timer only shows on items opened in the side drawer from a Table view, and the monday.com desktop app is not supported.

monday.com has its own Harvest integration in the Automation center, under Integrate on a board. Its templates trigger actions in Harvest from monday activity, and monday’s note says it is not a two-way sync: items appear in Harvest as they were created on monday.com, and later changes have to be fixed by hand in Harvest.

Both are useful if your team plans work in monday.com and logs hours in Harvest. If what you want is hours, billable flags, or project totals visible on a monday board, keep reading.

Route 1: Import a Harvest report into a monday.com board

For a fixed period, a file import is the least effort and needs no new accounts.

  1. In Harvest, go to Reports > Time or build a Detailed time report under Reports > New report. Filter by client, project, or person, then export as CSV or Excel. The Export Harvest to Excel guide covers which report gives you which columns, and how permissions decide whose entries appear.
  2. Tidy the file before it reaches monday. Delete columns the board does not need, and make sure the first column holds something that reads well as an item name, such as Project: Task (Date) or the entry notes.
  3. In monday.com, open the board, click the arrow next to New item, and choose Import items. monday’s Excel import article describes the steps.
  4. Map each file column to an existing board column, create a new Text column, or skip it. Map Hours to a Numbers column and Date to a Date column, so the board can sum and filter them. Pick the group the rows land in.
  5. Choose the duplicate behavior: add every row as a new item, skip rows that match an existing item on a column you choose, or update matching items.

The duplicate step matters if you import more than once. Without a stable column to match on, a second import of an overlapping date range creates the same hours twice. Either import non-overlapping periods (one file per closed month into its own group) or add a column that uniquely identifies each row and match on that.

Imports have file size and row limits that monday lists in the same article. A full year of detailed time for a large team can exceed them, so split by quarter or by client if the upload is rejected.

Entries or totals?

Decide what one item on the board should be. One item per time entry gives you every note and lets the board filter by person, but a busy team produces thousands of items a month. For most project boards, a Harvest project or task summary is more useful: one row per project with total and billable hours, imported into a column next to the matching project item. Running the time report grouped by project before exporting gives you that shape directly.

Route 2: Zapier for new and updated time entries

When hours should appear on the board without anyone downloading files, an automation tool takes over. Zapier’s Harvest integration has New Time Entry and Updated Time Entry triggers, each with an option to exclude entries created as running timers, and monday.com actions such as Create Item and Update Item. Zapier lists a ready-made template that updates monday.com columns when new time entries are added in Harvest.

Two patterns cover most boards:

  • One item per entry. New Time Entry, then Create Item on a time log board. Map spent date, hours, person, project, task, and notes to columns.
  • Roll hours onto an existing item. New Time Entry, then a step that finds the matching monday item (by project name or an ID column), then Update Item. Update Item writes the values you give it; it does not add the new hours to the old total for you. Either log each entry as a subitem and let monday sum the subitems, or calculate the new total in a Zapier step before writing it.

Harvest triggers in Zapier poll for new data rather than receiving a push, and Zapier says the check runs every 15 minutes on its Free plan. That is fine for a project board. It is not instant, so do not build a process that expects the board to reflect a timer the moment it stops. Excluding running timers also keeps half-finished entries from creating items you then have to correct.

Route 3: A small script against both APIs

A script makes sense when you need history as well as new entries, when the item-matching logic is more than a name lookup, or when you would rather not pay for task volume. It reads time entries from the Harvest API v2 and writes items with monday.com’s GraphQL API.

On the Harvest side you need a personal access token, your account ID, and a User-Agent header with an app name and contact email, as described in Harvest’s authentication guide. On the monday.com side you need an API token from your profile’s Developers area and the board ID from the board URL. Column IDs are not the column titles you see; list them first with this query:

query {
  boards(ids: [1234567890]) {
    columns { id title type }
  }
}

Then the sync itself. It asks Harvest for entries updated since the last run, creates a monday item for any entry it has not seen, and updates the item for entries it has. A small JSON file remembers which Harvest entry became which monday item.

import json, os, pathlib, datetime, requests

HARVEST = {
    "Authorization": f"Bearer {os.environ['HARVEST_TOKEN']}",
    "Harvest-Account-Id": os.environ["HARVEST_ACCOUNT_ID"],
    "User-Agent": "Harvest to monday sync (ops@example.com)",
}
MONDAY = {
    "Authorization": os.environ["MONDAY_TOKEN"],
    "API-Version": "2026-04",
    "Content-Type": "application/json",
}
BOARD_ID = os.environ["MONDAY_BOARD_ID"]
COLS = {"date": "date_col", "hours": "numbers_col", "person": "text_col",
        "project": "text_col2", "billable": "text_col3"}  # your column IDs
STATE = pathlib.Path("harvest_monday_state.json")

state = json.loads(STATE.read_text()) if STATE.exists() else {"since": None, "items": {}}

def harvest_entries(since):
    url = "https://api.harvestapp.com/v2/time_entries"
    params = {"is_running": "false"}
    if since:
        params["updated_since"] = since
    while url:
        page = requests.get(url, headers=HARVEST, params=params, timeout=30).json()
        yield from page["time_entries"]
        url, params = page["links"]["next"], None

def monday(query, variables):
    r = requests.post("https://api.monday.com/v2", headers=MONDAY,
                      json={"query": query, "variables": variables}, timeout=30)
    body = r.json()
    if "errors" in body:
        raise RuntimeError(body["errors"])
    return body["data"]

CREATE = """mutation ($board: ID!, $name: String!, $values: JSON!) {
  create_item(board_id: $board, item_name: $name, column_values: $values) { id } }"""
UPDATE = """mutation ($board: ID!, $item: ID!, $values: JSON!) {
  change_multiple_column_values(board_id: $board, item_id: $item, column_values: $values) { id } }"""

started = datetime.datetime.now(datetime.timezone.utc).isoformat()
for e in harvest_entries(state["since"]):
    values = json.dumps({
        COLS["date"]: {"date": e["spent_date"]},
        COLS["hours"]: str(e["hours"]),
        COLS["person"]: e["user"]["name"],
        COLS["project"]: e["project"]["name"],
        COLS["billable"]: "Yes" if e["billable"] else "No",
    })
    key = str(e["id"])
    if key in state["items"]:
        monday(UPDATE, {"board": BOARD_ID, "item": state["items"][key], "values": values})
    else:
        name = f'{e["project"]["name"]}: {e["task"]["name"]} ({e["spent_date"]})'
        item = monday(CREATE, {"board": BOARD_ID, "name": name, "values": values})
        state["items"][key] = item["create_item"]["id"]

state["since"] = started
STATE.write_text(json.dumps(state, indent=2))

Run it once with no state file to backfill, then on a schedule (cron, a CI job, or a serverless timer) to pick up changes. A few details explain why it is written this way:

  • updated_since catches edits, not only new entries. Harvest’s time entries reference supports filtering by update time, so a corrected entry is copied again and its item is updated rather than duplicated. Deleted entries do not show up in that list, so a deletion in Harvest leaves the item on the board until you remove it.
  • Pagination follows links.next. The loop keeps requesting until Harvest returns no next page, so a large backfill completes without counting pages.
  • Column values are a JSON string keyed by column ID. monday’s items reference shows create_item taking column_values in that form, and its date column reference uses {"date": "YYYY-MM-DD"}. Status and dropdown columns expect labels instead; send {"label": "Billable"} and pass create_labels_if_missing: true if the label might not exist yet.
  • Running timers are skipped. is_running=false keeps the board from holding a half-finished entry that changes every few minutes.

Keep both tokens in environment variables or a secret store, never in the script you commit. The Harvest token can read every entry your user can see, and the monday token can edit every board your user can.

Linking hours back to the item they were tracked on

If your team starts timers from monday items with the Harvest extension, the resulting time entries carry an external_reference object in the API with an ID and a permalink to the source item. That is the most reliable way to roll hours onto the right monday item, much better than matching by name. Inspect a few entries first to see exactly what the ID and permalink contain for your boards, then use them in the lookup instead of creating a new item per entry.

Which route to use

  • A closed month or a one-off review: export a time report and use Import items.
  • New hours on a board, no code: Zapier with New Time Entry, and Updated Time Entry if corrections matter.
  • History plus ongoing sync, custom matching, or high volume: the API script.
  • Timing hours from monday items: the Harvest browser extension, which is tracking, not exporting.

When the timesheet is a PDF, not a Harvest account

Agencies often receive hours from subcontractors who use their own Harvest account or no tool at all. What arrives is a detailed time PDF attached to an invoice, a screenshot, or a signed paper sheet. None of the routes above can read those, because there is nothing to export from.

OCR closes that gap. With ocrskill.com you send the file and get Markdown back, with tables kept as Markdown tables:

curl https://api.ocrskill.com/get-key.json

curl https://api.ocrskill.com/ocr \
  -H "Authorization: Bearer sk-your-key-here" \
  -H "Content-Type: application/pdf" \
  --data-binary "@subcontractor-hours.pdf"

Convert the table rows to CSV and feed them to the same monday import from Route 1, or pass them through the script’s create_item call. Check the totals against the document before the rows land on a board people bill from, and send handwritten or stamped sheets to a person for review. The Export Typeform to monday.com guide uses the same pattern for paper forms, and if you need the Harvest data as raw JSON for another app, see Export Harvest to JSON.

Conclusion

The Harvest and monday.com integrations that ship with either product send work from monday into Harvest’s timers. Getting hours back onto a board is a separate job. A report import handles closed periods if you match duplicates on a stable column. Zapier keeps a board current within its polling interval, but Update Item overwrites values rather than adding to them. A script with updated_since and a stored ID map is the route that handles history, corrections, and the external_reference link to the original item. Deletions in Harvest are the one change none of these routes carry over automatically, so plan a periodic cleanup if your boards drive billing.