# contractorOps.import.hours

Bring past timesheets in as history from a table (source notion with databaseId, the database's link or ID, or csv with rows; mapping names the column for any fields whose column names don't match, and the others are matched by name). Fields: contractor (or firstName and lastName, or email), period (a date or a range, with optional periodEnd), hours, and optional rate, amount, tax, currency, paid, notes and submitted. Rows are matched to contractors by email, then name. Imported hours appear in each contractor's history, spending and on-time insights, but never become pay periods or payouts. A row that is a timesheet already here (the same Notion page or CSV row, or the same contractor's for the same hours starting or ending the same day) updates it instead of adding a duplicate: edited dates, hours, rate, amount, GST/HST and notes are taken, a blank cell erases nothing, and Paid marks an unpaid timesheet paid but an unpaid row never marks a paid one unpaid. The result lists what was added, corrected and marked paid in saved (counts and rows) and in summaryText, and afterwards new unpaid timesheets are settled against bank payments already linked to the contractor (payments). A column whose name says it holds payment or tax details is never read, and problems says so when mapping names one. With preview true nothing is saved and changes says what would happen.

`POST /api/v1/accounting/contractorOps.import.hours`

Permissions: `accounting:read`, `accounting:write` · Roles: admin, finance · Idempotency key required

MCP tool: `accounting_contractor_ops_import_hours`

## Fields

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `source` | enum | Yes | notion reads a Notion database on the server; csv takes the rows of a parsed CSV file. One of: `csv`, `notion`. |
| `rows` | array of maps |  | For csv: each row as column name to value, at most 500. 1–500 items. |
| `databaseId` | string |  | For notion: the database's link or ID. The database must be shared with the Oatmilk connection. 1–2000 characters. |
| `mapping` | object |  | The column for any fields whose column names don't match (null for none). Fields left out, or every field when it's omitted, are matched by column name; the result returns the mapping it used, so a preview shows the suggested one. |
| `mapping.contractor` | string or null |  | The column holding Contractor (required), or null when the table has none. |
| `mapping.firstName` | string or null |  | The column holding First name, or null when the table has none. |
| `mapping.lastName` | string or null |  | The column holding Last name, or null when the table has none. |
| `mapping.email` | string or null |  | The column holding Email, or null when the table has none. |
| `mapping.period` | string or null |  | The column holding Period (required), or null when the table has none. |
| `mapping.periodEnd` | string or null |  | The column holding Period end, or null when the table has none. |
| `mapping.hours` | string or null |  | The column holding Hours (required), or null when the table has none. |
| `mapping.rate` | string or null |  | The column holding Rate, or null when the table has none. |
| `mapping.amount` | string or null |  | The column holding Amount, or null when the table has none. |
| `mapping.tax` | string or null |  | The column holding GST/HST, or null when the table has none. |
| `mapping.currency` | string or null |  | The column holding Currency, or null when the table has none. |
| `mapping.paid` | string or null |  | The column holding Paid, or null when the table has none. |
| `mapping.notes` | string or null |  | The column holding Notes, or null when the table has none. |
| `mapping.submitted` | string or null |  | The column holding Submitted, or null when the table has none. |
| `preview` | boolean |  | true checks every row and saves nothing. |
| `idempotencyKey` | string | Yes | Any unique text you generate once per intended change, so a retried request only happens once. Send it as the Idempotency-Key header instead if you prefer; if you send both they must match. 8–200 characters. |

## Example request

```bash
curl https://app.getoatmilk.com/api/v1/accounting/contractorOps.import.hours \
  -H "Authorization: Bearer $OATMILK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "source": "csv",
  "rows": [
    {}
  ]
}'
```

Reference page: https://app.getoatmilk.com/docs/api/contractorOps.import.hours
