# Get tracked hours for an opportunity (https://terac.com/docs/developers/reference/getOpportunityHours)

`GET https://terac.com/api/external/v2/opportunities/{opportunityId}/hours`

Hours each expert worked on this opportunity, one row per person and date. Time spent on your other opportunities, and time clocked in but not spent on work, is not included. The current day is partial and refreshes through the day; `last_updated_at` says when the hours were last refreshed.

## Authorization

Requires a Bearer token via the `Authorization` header.

```
Authorization: Bearer <token>
```

## Path Parameters

- `opportunityId` (string) **(required)**: ID of the opportunity, as returned by `POST /opportunities` or `GET /opportunities`.

## Query Parameters

- `from` (string) **(required)**: Start of the window, inclusive: an ISO 8601 UTC timestamp such as `2026-09-08T00:00:00Z`, or a bare `2026-09-08` read as midnight UTC. A person's day counts when the instant it opened in their own tracker timezone falls in the window, so a day is never returned in halves.
- `to` (string) **(required)**: End of the window, exclusive, in the same UTC form: a day that opened exactly at `to` belongs to the next window, so consecutive requests neither double count nor skip. At most 92 days after `from`.

## Responses

### 200: Tracked hours per person and date, with totals for the range.

- `last_updated_at` (string, nullable) **(required)**: When these hours were last refreshed, regardless of the range asked for. Null when no hours have ever been recorded for this opportunity, which tells an empty `data` apart from a range nobody worked in.
- `total_seconds` (integer) **(required)**: Every row's `seconds` added together, for the range you asked for.
- `total_hours` (number) **(required)**: `total_seconds` converted once. Not the rows' `hours` added together, which drifts by minutes over a few thousand rows.
- `data` (array) **(required)**
  - `participant_id` (string, nullable) **(required)**: The Terac participant these hours belong to. Null for a worker we cannot yet match to an account; their hours still count toward the totals.
  - `submission_id` (string, nullable) **(required)**: Their submission on this opportunity. Null when they hold no active one.
  - `email` (string, nullable) **(required)**: The participant's email. Null when we hold none for them.
  - `date` (string) **(required)**: `YYYY-MM-DD`, the day this work is credited to, in `timezone`.
  - `timezone` (string) **(required)**: The IANA timezone `date` is expressed in for this person.
  - `seconds` (integer) **(required)**: Seconds this participant worked on this opportunity on this date. The authoritative figure: derive your own totals from this rather than from `hours`.
  - `hours` (number) **(required)**: `seconds` converted once and rounded to two decimals. Adding rounded hours across many rows drifts; add `seconds` and convert at the end.
### 400: Invalid input data

- `message` (string) **(required)**: The error message
- `code` (string) **(required)**: The error code
- `issues` (array): An array of issues that were responsible for the error
  - `message` (string) **(required)**
### 401: The API key is missing, invalid, disabled, expired or revoked, its owner no longer exists, or it is not linked to an organization. The body is nested under `error`.

- `error` (object) **(required)**
  - `code` (string) **(required)**: one of: `UNAUTHORIZED`: The error code.
  - `message` (string) **(required)**: What went wrong.
### 403: Either the account that owns the API key is banned or deleted (from the key check, nested under `error`), or the key's owner lacks the organization permission this operation needs (from the operation, with `code` and `message` at the top level).

- One of: API key error (403)
  - `error` (object) **(required)**
    - `code` (string) **(required)**: one of: `FORBIDDEN`: The error code.
    - `message` (string) **(required)**: What went wrong.
- One of: Insufficient access error (403)
  - `message` (string) **(required)**: The error message
  - `code` (string) **(required)**: The error code
  - `issues` (array): An array of issues that were responsible for the error
    - `message` (string) **(required)**
### 404: Not found

- `message` (string) **(required)**: The error message
- `code` (string) **(required)**: The error code
- `issues` (array): An array of issues that were responsible for the error
  - `message` (string) **(required)**
### 429: The API key is rate limited. A key accepts 100 requests, then refuses every request until more than 60 seconds pass with no accepted request; refused requests do not extend the wait. Wait the number of seconds in `Retry-After`, then retry. The body is nested under `error`.

- `error` (object) **(required)**
  - `code` (string) **(required)**: one of: `RATE_LIMITED`: The error code.
  - `message` (string) **(required)**: What went wrong.
### 500: Internal server error

- `message` (string) **(required)**: The error message
- `code` (string) **(required)**: The error code
- `issues` (array): An array of issues that were responsible for the error
  - `message` (string) **(required)**