Export Harvest to Redshift: ELT Connectors or an API to S3 to COPY Pipeline
← All posts
GuideNov 4, 2026· 8 min read

Export Harvest to Redshift: ELT Connectors or an API to S3 to COPY Pipeline

To export Harvest to Redshift, pick one of two routes. If your team already runs an ELT platform, Fivetran and Airbyte both have Harvest connectors that sync time entries, projects, clients, invoices, and users into Redshift tables on a schedule. If you want control over the schema, or you only care about hours, a short job can page through the Harvest API v2, write newline-delimited JSON to S3, and load it with COPY into a SUPER column. Harvest has no built-in Redshift export, and its CSV downloads are fine for a one-off load but awkward to repeat every night.

This guide covers both routes, the SQL you need to keep reruns safe, and how to fold in timesheets that only exist as PDFs or paper. If you need the data in a spreadsheet instead, see Export Harvest to Excel, and for raw API output the Export Harvest to JSON guide goes deeper on the endpoints.

Route 1: Fivetran or Airbyte

A managed connector is the least code you will write, and it handles retries and rate limits for you.

  • Fivetran has a Harvest connector that incrementally syncs tables including TIME_ENTRY, PROJECT, CLIENT, TASK, USERS, INVOICE, EXPENSE, and the user and task assignment tables. Point it at a Redshift destination and the tables appear with foreign keys you can join.
  • Airbyte has a Harvest source with incremental streams for time entries, projects, invoices, expenses, and more, plus report streams such as Time Projects Report and Uninvoiced Report. It needs your Harvest Account ID and either OAuth or a personal access token, and a start date that controls how far back the first sync reaches.

Two limits are worth knowing before you commit. Harvest’s API allows 100 general requests per 15 seconds and only 100 report requests per 15 minutes, which Airbyte’s docs call out, so the report streams on a long account can make the first sync slow. And a stream comes back empty, not failed, when the Harvest user behind the credentials cannot see that resource. If TIME_ENTRY lands with only one person’s hours, check that the connection uses an administrator.

Route 2: Harvest API to S3 to COPY

When you only need time entries, or you want the raw documents kept intact, the hand-built pipeline is small. Each time entry becomes one JSON line in S3, and Redshift stores it whole in a SUPER column.

Step 1: pull time entries as newline-delimited JSON

The time entries endpoint returns up to 2,000 records per page and includes next_page in the response, so you loop until it is null. The updated_since filter keeps scheduled runs incremental, which matters because people edit and approve last week’s hours, not just today’s.

import json
import os
import boto3
import requests

HEADERS = {
    "Authorization": f"Bearer {os.environ['HARVEST_TOKEN']}",
    "Harvest-Account-Id": os.environ["HARVEST_ACCOUNT_ID"],
    "User-Agent": "redshift-loader (data@example.com)",
}
BUCKET = "my-warehouse-landing"
KEY = "harvest/time_entries/batch.jsonl"

def fetch_time_entries(updated_since):
    params = {"per_page": 2000, "page": 1, "updated_since": updated_since}
    while params["page"]:
        r = requests.get(
            "https://api.harvestapp.com/v2/time_entries",
            headers=HEADERS, params=params, timeout=60,
        )
        r.raise_for_status()
        body = r.json()
        yield from body["time_entries"]
        params["page"] = body["next_page"]

lines = [json.dumps(e) for e in fetch_time_entries("2026-10-01T00:00:00Z")]
boto3.client("s3").put_object(
    Bucket=BUCKET, Key=KEY, Body="\n".join(lines).encode("utf-8")
)

Store the newest updated_at you saw and pass it as updated_since next time, minus a few minutes of overlap. The merge step below makes the overlap harmless.

Step 2: load into a SUPER column

Redshift’s COPY into SUPER guide describes FORMAT JSON 'noshred', which loads each line as one document instead of mapping keys to columns. A time entry has nested user, client, project, task, and invoice objects, and keeping them nested means a new Harvest field never breaks the load.

CREATE TABLE harvest_time_entries (
    id          BIGINT NOT NULL,
    spent_date  DATE,
    updated_at  TIMESTAMPTZ,
    payload     SUPER
);

CREATE TEMP TABLE harvest_stage (doc SUPER);

COPY harvest_stage
FROM 's3://my-warehouse-landing/harvest/time_entries/batch.jsonl'
IAM_ROLE 'arn:aws:iam::123456789012:role/RedshiftLoad'
FORMAT JSON 'noshred';

Step 3: MERGE on the time entry id

The same entry shows up again whenever someone edits its notes, it gets approved, or it is invoiced. MERGE keyed on id updates it in place:

MERGE INTO harvest_time_entries
USING (
    SELECT doc.id::BIGINT                           AS id,
           doc.spent_date::VARCHAR::DATE            AS spent_date,
           doc.updated_at::VARCHAR::TIMESTAMPTZ     AS updated_at,
           doc                                      AS payload
    FROM harvest_stage
) s
ON harvest_time_entries.id = s.id
WHEN MATCHED THEN UPDATE SET
    spent_date = s.spent_date,
    updated_at = s.updated_at,
    payload    = s.payload
WHEN NOT MATCHED THEN INSERT
    VALUES (s.id, s.spent_date, s.updated_at, s.payload);

Step 4: a flat view for reporting

PartiQL navigation reads nested attributes with dot notation, so one view gives analysts ordinary columns:

CREATE VIEW harvest_hours AS
SELECT id,
       spent_date,
       payload.user.name::VARCHAR      AS person,
       payload.client.name::VARCHAR    AS client,
       payload.project.name::VARCHAR   AS project,
       payload.task.name::VARCHAR      AS task,
       payload.hours::DECIMAL(10,2)    AS hours,
       payload.rounded_hours::DECIMAL(10,2) AS rounded_hours,
       payload.billable::BOOLEAN       AS billable,
       payload.billable_rate::DECIMAL(10,2) AS billable_rate,
       payload.cost_rate::DECIMAL(10,2)     AS cost_rate,
       payload.approval_status::VARCHAR AS approval_status,
       payload.invoice.number::VARCHAR  AS invoice_number
FROM harvest_time_entries;

Use rounded_hours if your numbers must match Harvest’s own reports and invoices, since that field follows the account’s rounding setting. Use hours for raw effort.

Some details that save a debugging session:

  • Deletes do not show up. updated_since returns changed entries, not removed ones. If deleted hours must disappear from Redshift, run a periodic full pull for recent months and remove ids that are no longer returned.
  • Permissions decide the data. Administrators see every entry, managers see their teams and projects, and members see only their own time. Use an administrator token for the loader.
  • Skip running timers. An entry with is_running true will keep changing. Either filter it out with is_running=false or accept that the next run will update it.

When the timesheet never reached Harvest

Contractors send PDF timesheets, field crews fill in paper sheets, and an acquired team may hand over exports from a tool you never had access to. None of those have a Harvest id. If the warehouse should include them, read each page into JSON with the same field names and load it beside the API data.

With OCRskill you name the fields and get JSON back:

curl "https://api.ocrskill.com/ocr.json?fields=employee_name,date,project,hours,notes?" \
  -H "Authorization: Bearer sk-your-key-here" \
  -F "file=@contractor-timesheet-oct.pdf"

Required fields return a 400 when the page cannot support them, so an unreadable scan does not turn into a row of nulls, and optional keys end in ?. Write each result as one JSON line with a source tag and the file name as its key, load it with the same COPY ... FORMAT JSON 'noshred', and union it into the reporting view with a source column. The OCR JSON API reference lists supported fields, and the Typeform to Redshift guide uses the same staging pattern for form responses.

Which route to pick

Situation Route
ELT platform already in place, many Harvest objects Fivetran or Airbyte
Mostly time entries, custom schema, cheap reruns API v2, S3, COPY into SUPER, MERGE
One historical load CSV from Harvest’s Export all time, then COPY ... CSV
PDF or paper timesheets OCR to JSON, then the same COPY path

Conclusion

Every route ends with one row per time entry keyed on Harvest’s id. A connector gets you there with configuration and a monthly bill. The API pipeline gets you there with about thirty lines of Python and three SQL statements, and keeps the full document so a new Harvest field never breaks a load. Whichever you choose, run the loader as an administrator, decide early whether reports use hours or rounded_hours, and plan a periodic full pull, because deleted entries are the one change an incremental sync will never tell you about.