openapi: 3.1.0
info:
  title: Tally API
  version: 1.0.0
  summary: Read and write your Tally time tracking data.
  description: |
    The Tally API lets Zapier, Make, n8n, your own scripts and other apps read and change your Tally data:
    sessions (time entries), clients, the running timer, and reports.

    **Auth.** Make a personal API key in Tally under API & webhooks and send it as
    `Authorization: Bearer tally_...`. Keys are read-only, or read and write. A key only ever sees its owner's data.
    The API is part of Tally Premium (and a Pro plan bought earlier).

    **Times.** Timestamps are ISO 8601 in UTC (`2026-10-15T13:30:00.000Z`). A session's `date` is the day in your
    time zone (set in Tally). Hours are decimals (`1.5` is an hour and a half).

    **Errors** look like `{ "error": { "code": "validation_error", "message": "...", "field": "client" } }`.

    **Pages.** Lists that can grow return `next_cursor`; pass it back as `cursor` for the next page. It's null on the last page.

    **Limits.** 120 requests a minute per key. Every response has `RateLimit-Limit`, `RateLimit-Remaining` and
    `RateLimit-Reset` (seconds). Over the limit you get 429 with `Retry-After`.

    **Retries.** Send an `Idempotency-Key` header with a POST and a retry with the same key and body gets the first
    answer back instead of adding the session twice (kept for 24 hours).

    **Versions.** Everything is under `/v1`. Within v1 we only add fields and endpoints. If we ever have to remove or rename one, we'll give notice first.

    **Terms.** Using the API is covered by the Tally Terms of Service (section 10, API and Webhooks).
  termsOfService: https://www.tallytimetracker.com/terms
  contact:
    name: Tally support
    url: https://www.tallytimetracker.com/support
servers:
  - url: https://www.tallytimetracker.com/api
security:
  - apiKey: []
tags:
  - name: Account
  - name: Sessions
    description: Time entries.
  - name: Clients
  - name: Timer
    description: The running timer, shared with the Tally apps. A timer started here shows on your phone, Mac and the website at once.
  - name: Reports
  - name: Webhooks
    description: |
      URLs Tally calls when your sessions or timer change. Each call is a POST with a JSON body (see the `webhooks`
      section) and a `Tally-Signature: t=<unix seconds>, v1=<hex>` header, where v1 is HMAC-SHA256 of `t`, a dot and the
      raw body, keyed with the webhook's secret. Answer with any 2xx within 10 seconds; anything else is retried after
      1m, 5m, 30m, 2h and 6h, and after that the webhook is marked failing and gets no new events until it's turned
      back on in Tally.

paths:
  /v1/me:
    get:
      tags: [Account]
      summary: You and this key
      operationId: getMe
      responses:
        '200':
          description: Your account settings and the key's scopes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user_id: { type: string, format: uuid }
                  name: { type: [string, 'null'] }
                  email: { type: [string, 'null'] }
                  time_zone: { type: string, example: America/New_York }
                  week_start: { type: string, enum: [monday, sunday] }
                  currency: { type: string, example: USD }
                  key:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      name: { type: string }
                      scopes: { type: array, items: { type: string, enum: [read, write] } }
        default: { $ref: '#/components/responses/Error' }

  /v1/sessions:
    get:
      tags: [Sessions]
      summary: List sessions
      description: Your sessions, newest start first. Filters combine.
      operationId: listSessions
      parameters:
        - { name: from, in: query, description: 'First day, YYYY-MM-DD (inclusive)', schema: { type: string, format: date } }
        - { name: to, in: query, description: 'Last day, YYYY-MM-DD (inclusive)', schema: { type: string, format: date } }
        - { name: client, in: query, description: Only this client (any case), schema: { type: string } }
        - { name: ticket, in: query, description: 'Only this ticket key, e.g. SCRUM-12', schema: { type: string } }
        - { name: created_after, in: query, description: Only sessions added after this time. Handy for polling for new ones., schema: { type: string, format: date-time } }
        - { $ref: '#/components/parameters/limit' }
        - { $ref: '#/components/parameters/cursor' }
      responses:
        '200':
          description: A page of sessions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessions: { type: array, items: { $ref: '#/components/schemas/Session' } }
                  next_cursor: { type: [string, 'null'] }
        default: { $ref: '#/components/responses/Error' }
    post:
      tags: [Sessions]
      summary: Add a session
      description: |
        Adds time on one of your clients. Give `start` and `end` (full timestamps, or 24-hour clock times like `09:30`
        on `date` in your time zone), or `hours`. No future time; 1 minute to 16 hours. The session shows "via API" in Tally.
        Needs a read and write key.
      operationId: createSession
      parameters:
        - { $ref: '#/components/parameters/idempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SessionCreate' }
            examples:
              timestamps:
                value: { client: Acme, start: '2026-10-15T13:00:00Z', end: '2026-10-15T14:30:00Z', title: Fix login bug, ticket: { key: SCRUM-12, url: 'https://acme.atlassian.net/browse/SCRUM-12' } }
              clock:
                value: { client: Acme, date: '2026-10-15', start: '09:00', end: '10:30', note: Sprint review, activity: meeting }
              hours:
                value: { client: Acme, date: '2026-10-15', hours: 1.5, billable: false }
      responses:
        '201':
          description: The new session.
          content:
            application/json:
              schema: { type: object, properties: { session: { $ref: '#/components/schemas/Session' } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/sessions/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Sessions]
      summary: Get a session
      operationId: getSession
      responses:
        '200':
          description: The session.
          content:
            application/json:
              schema: { type: object, properties: { session: { $ref: '#/components/schemas/Session' } } }
        default: { $ref: '#/components/responses/Error' }
    patch:
      tags: [Sessions]
      summary: Change a session
      description: |
        Give only the fields to change. `title`, `note`, `activity` and `ticket` can be null to clear them. New times
        follow the same rules as adding one; a session with start and end times needs the new start and end together.
        Time already posted to a Jira worklog isn't changed there. Needs a read and write key.
      operationId: updateSession
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SessionUpdate' }
      responses:
        '200':
          description: The changed session.
          content:
            application/json:
              schema: { type: object, properties: { session: { $ref: '#/components/schemas/Session' } } }
        default: { $ref: '#/components/responses/Error' }
    delete:
      tags: [Sessions]
      summary: Delete a session
      description: Deletes it for good. Needs a read and write key.
      operationId: deleteSession
      responses:
        '204': { description: Deleted. }
        default: { $ref: '#/components/responses/Error' }

  /v1/clients:
    get:
      tags: [Clients]
      summary: List clients
      description: Every client with a rate set up or time logged, with rate, billing, budget and hours used.
      operationId: listClients
      responses:
        '200':
          description: Your clients.
          content:
            application/json:
              schema:
                type: object
                properties:
                  currency: { type: string, example: USD }
                  clients: { type: array, items: { $ref: '#/components/schemas/Client' } }
        default: { $ref: '#/components/responses/Error' }
    post:
      tags: [Clients]
      summary: Add a client
      description: Needs a read and write key.
      operationId: createClient
      parameters:
        - { $ref: '#/components/parameters/idempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [name]
                  properties:
                    name: { type: string, maxLength: 200 }
                - { $ref: '#/components/schemas/ClientSettings' }
            example: { name: Globex, hourly_rate: 95, budget_kind: money, budget_amount: 4000, budget_period: period }
      responses:
        '201':
          description: The new client.
          content:
            application/json:
              schema: { type: object, properties: { client: { $ref: '#/components/schemas/Client' } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/clients/{name}:
    parameters:
      - { name: name, in: path, required: true, description: "The client's name, URL-encoded (any case).", schema: { type: string } }
    get:
      tags: [Clients]
      summary: Get a client
      operationId: getClient
      responses:
        '200':
          description: The client.
          content:
            application/json:
              schema: { type: object, properties: { client: { $ref: '#/components/schemas/Client' } } }
        default: { $ref: '#/components/responses/Error' }
    patch:
      tags: [Clients]
      summary: Change a client's rate, budget or billing
      description: Renaming isn't available through the API yet. Needs a read and write key.
      operationId: updateClient
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClientSettings' }
      responses:
        '200':
          description: The changed client.
          content:
            application/json:
              schema: { type: object, properties: { client: { $ref: '#/components/schemas/Client' } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/timer:
    get:
      tags: [Timer]
      summary: The running timer
      operationId: getTimer
      responses:
        '200':
          description: The timer, or null when none is running or paused.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TimerResponse' }
        default: { $ref: '#/components/responses/Error' }
    patch:
      tags: [Timer]
      summary: Change the running timer
      description: Change its client, title, note, type of work or ticket. Needs a read and write key.
      operationId: updateTimer
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TimerFields' }
      responses:
        '200':
          description: The changed timer.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TimerResponse' }
        default: { $ref: '#/components/responses/Error' }

  /v1/timer/start:
    post:
      tags: [Timer]
      summary: Start a timer
      description: |
        Starts a timer on every device. If one is already running or paused you get 409 `timer_running`, unless you
        pass `replace: true`, which stops and saves that one first (returned as `saved_session`). Needs a read and write key.
      operationId: startTimer
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - { $ref: '#/components/schemas/TimerFields' }
                - type: object
                  properties:
                    started_at: { type: string, format: date-time, description: 'When it started, if earlier than now (up to 16 hours ago).' }
                    replace: { type: boolean, default: false }
            example: { client: Acme, title: Code review, ticket: { key: ENG-4, url: 'https://linear.app/acme/issue/ENG-4', source: linear } }
      responses:
        '201':
          description: The new timer.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/TimerResponse' }
                  - type: object
                    properties:
                      saved_session: { oneOf: [{ $ref: '#/components/schemas/Session' }, { type: 'null' }] }
        default: { $ref: '#/components/responses/Error' }

  /v1/timer/pause:
    post:
      tags: [Timer]
      summary: Pause the timer
      description: Does nothing if it's already paused. Needs a read and write key.
      operationId: pauseTimer
      responses:
        '200': { description: The timer., content: { application/json: { schema: { $ref: '#/components/schemas/TimerResponse' } } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/timer/resume:
    post:
      tags: [Timer]
      summary: Resume the timer
      description: Does nothing if it's already running. Needs a read and write key.
      operationId: resumeTimer
      responses:
        '200': { description: The timer., content: { application/json: { schema: { $ref: '#/components/schemas/TimerResponse' } } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/timer/stop:
    post:
      tags: [Timer]
      summary: Stop the timer and save the session
      description: |
        Saves the time worked (pauses left out) as a session and ends the timer on every device. You can send last
        changes with it (for example the client, if the timer has none). Under a minute is refused with `too_short`;
        use discard instead. Needs a read and write key.
      operationId: stopTimer
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TimerFields' }
      responses:
        '200':
          description: The saved session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  timer: { type: 'null' }
                  session: { $ref: '#/components/schemas/Session' }
        default: { $ref: '#/components/responses/Error' }

  /v1/timer/discard:
    post:
      tags: [Timer]
      summary: Throw the timer away
      description: Ends it on every device without saving anything. Needs a read and write key.
      operationId: discardTimer
      responses:
        '200': { description: No timer now., content: { application/json: { schema: { $ref: '#/components/schemas/TimerResponse' } } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/reports/summary:
    get:
      tags: [Reports]
      summary: Hours and earnings for a period
      description: Totals, billable split, estimated earnings, and breakdowns by client, ticket and type of work.
      operationId: getSummary
      parameters:
        - { $ref: '#/components/parameters/period' }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: client, in: query, schema: { type: string } }
      responses:
        '200':
          description: The summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Summary' }
        default: { $ref: '#/components/responses/Error' }

  /v1/contracts:
    get:
      tags: [Reports]
      summary: Contract status
      description: Required hours this period, makeup or banked hours, hours worked and left, cap use and warnings, for each client contract.
      operationId: listContracts
      parameters:
        - { name: client, in: query, schema: { type: string } }
        - { name: include_history, in: query, description: Include every finished period., schema: { type: boolean, default: false } }
      responses:
        '200':
          description: Contracts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  today: { type: string, format: date }
                  contracts: { type: array, items: { type: object, additionalProperties: true } }
        default: { $ref: '#/components/responses/Error' }

  /v1/invoices:
    get:
      tags: [Reports]
      summary: List invoices
      operationId: listInvoices
      parameters:
        - { name: client, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, example: paid } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
      responses:
        '200':
          description: Invoices, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  invoices:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        invoice_number: { type: [string, 'null'] }
                        client: { type: string }
                        start_date: { type: [string, 'null'], format: date }
                        end_date: { type: [string, 'null'], format: date }
                        total_hours: { type: [number, 'null'] }
                        hourly_rate: { type: [number, 'null'] }
                        total_amount: { type: [number, 'null'] }
                        tax_amount: { type: [number, 'null'] }
                        status: { type: [string, 'null'] }
                        created_at: { type: string, format: date-time }
                        paid_at: { type: [string, 'null'], format: date-time }
                        stripe_invoice_url: { type: [string, 'null'] }
        default: { $ref: '#/components/responses/Error' }

  /v1/untracked:
    get:
      tags: [Reports]
      summary: Work you didn't track
      description: Blocks of GitHub and Figma activity that no session covers. Needs GitHub or Figma connected in Tally.
      operationId: listUntracked
      parameters:
        - { name: days, in: query, schema: { type: integer, minimum: 1, maximum: 31, default: 7 } }
      responses:
        '200':
          description: Untracked blocks, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  days: { type: integer }
                  total_hours: { type: number }
                  blocks: { type: array, items: { type: object, additionalProperties: true } }
        default: { $ref: '#/components/responses/Error' }

  /v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List your webhooks
      operationId: listWebhooks
      responses:
        '200':
          description: Your webhooks, oldest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks: { type: array, items: { $ref: '#/components/schemas/Webhook' } }
        default: { $ref: '#/components/responses/Error' }
    post:
      tags: [Webhooks]
      summary: Add a webhook
      description: Needs a read and write key. Up to 10 webhooks. The answer is the only time the signing secret is shown.
      operationId: createWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              additionalProperties: false
              properties:
                url: { type: string, format: uri, maxLength: 2000, description: 'A public https:// address.' }
                events: { type: array, minItems: 1, items: { $ref: '#/components/schemas/WebhookEventType' } }
                description: { type: string, maxLength: 100, description: 'A name to tell it apart, like "Zapier: new time".' }
      responses:
        '201':
          description: Added.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook: { $ref: '#/components/schemas/Webhook' }
                  secret: { type: string, example: whsec_..., description: Signs every delivery. Keep it safe; it isn't shown again. }
        default: { $ref: '#/components/responses/Error' }
  /v1/webhooks/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Webhooks]
      summary: Get a webhook
      operationId: getWebhook
      responses:
        '200':
          description: The webhook.
          content:
            application/json:
              schema: { type: object, properties: { webhook: { $ref: '#/components/schemas/Webhook' } } }
        default: { $ref: '#/components/responses/Error' }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook
      description: Tally stops calling it at once, including retries.
      operationId: deleteWebhook
      responses:
        '204': { description: Deleted. }
        default: { $ref: '#/components/responses/Error' }

webhooks:
  sessionEvent:
    post:
      tags: [Webhooks]
      summary: session.created, session.updated, session.deleted
      operationId: sessionEvent
      description: |
        Time was added (in any app, by the timer, by Claude or through the API), changed, or deleted. For
        session.deleted, `data.session` is the session as it was.
      parameters:
        - { name: Tally-Event, in: header, required: true, schema: { $ref: '#/components/schemas/WebhookEventType' } }
        - { name: Tally-Delivery, in: header, required: true, description: The event id; the same on every retry., schema: { type: string, format: uuid } }
        - { name: Tally-Signature, in: header, required: true, schema: { type: string, example: 't=1729700000, v1=6b1d...' } }
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - { $ref: '#/components/schemas/WebhookDelivery' }
                - type: object
                  properties:
                    data: { type: object, properties: { session: { $ref: '#/components/schemas/Session' } } }
      responses:
        '200': { description: Any 2xx means received. }
  timerEvent:
    post:
      tags: [Webhooks]
      summary: timer.started, timer.paused, timer.resumed, timer.stopped
      operationId: timerEvent
      description: |
        The shared timer changed state on any device. Edits to a running timer (a new note) send nothing. On
        timer.stopped, `data.timer.state` is `stopped` and `elapsed_seconds` the time worked; it's sent for a discarded
        timer too, and saved time follows as session.created.
      parameters:
        - { name: Tally-Event, in: header, required: true, schema: { $ref: '#/components/schemas/WebhookEventType' } }
        - { name: Tally-Delivery, in: header, required: true, schema: { type: string, format: uuid } }
        - { name: Tally-Signature, in: header, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - { $ref: '#/components/schemas/WebhookDelivery' }
                - type: object
                  properties:
                    data: { type: object, properties: { timer: { $ref: '#/components/schemas/Timer' } } }
      responses:
        '200': { description: Any 2xx means received. }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: tally_ followed by 40 characters
      description: A personal API key from Tally → API & webhooks.

  parameters:
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    cursor:
      name: cursor
      in: query
      description: next_cursor from the previous page.
      schema: { type: string }
    period:
      name: period
      in: query
      description: A named period. Ignored when from or to is given. Default this_week.
      schema: { type: string, enum: [today, this_week, last_week, this_month, last_month, this_year] }
    idempotencyKey:
      name: Idempotency-Key
      in: header
      description: Any unique string (up to 200 characters). A retry with the same key and body returns the first answer.
      schema: { type: string, maxLength: 200 }

  responses:
    Error:
      description: |
        An error. Codes: `unauthorized`, `key_revoked` (401); `plan_required`, `insufficient_scope`, `demo_read_only` (403);
        `not_found`, `no_timer` (404); `method_not_allowed` (405); `duplicate_session`, `client_exists`, `timer_running`,
        `timer_changed` (409); `invalid_json` (400); `validation_error`, `client_required`, `too_short`,
        `idempotency_mismatch` (422); `rate_limited` (429); `server_error` (500).
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                required: [code, message]
                properties:
                  code: { type: string }
                  message: { type: string }
                  field: { type: string, description: 'The input field the error is about, when there is one.' }

  schemas:
    Activity:
      type: [string, 'null']
      enum: [build, research, meeting, review, admin, null]
      description: Type of work.

    Ticket:
      type: object
      required: [key]
      properties:
        key: { type: string, maxLength: 100, example: SCRUM-12 }
        url: { type: [string, 'null'], description: An https link to the ticket. Required on the timer. }
        title: { type: [string, 'null'], maxLength: 300 }
        source: { type: string, enum: [jira, linear, notion, github, todoist, figma, asana, clickup, trello, monday, basecamp], default: jira }

    Session:
      type: object
      properties:
        id: { type: string, format: uuid }
        date: { type: string, format: date, description: The day in your time zone. }
        client: { type: string }
        hours: { type: number, example: 1.5 }
        start: { type: [string, 'null'], format: date-time, description: Null for time typed in as hours. }
        end: { type: [string, 'null'], format: date-time }
        title: { type: [string, 'null'] }
        note: { type: [string, 'null'] }
        billable: { type: boolean }
        activity: { $ref: '#/components/schemas/Activity' }
        ticket: { oneOf: [{ $ref: '#/components/schemas/Ticket' }, { type: 'null' }] }
        manual: { type: boolean, description: Typed in rather than timed. }
        source: { type: [string, 'null'], description: 'Where it was added: web, ios, mac, android, watch, claude, api ...' }
        added_by: { type: [string, 'null'], description: 'The team admin who added it for you, if one did.' }
        created_at: { type: [string, 'null'], format: date-time }

    SessionCreate:
      type: object
      required: [client]
      additionalProperties: false
      properties:
        client: { type: string, description: One of your clients (any case). Add new ones with POST /v1/clients. }
        date: { type: string, format: date, description: 'Default: the day of start, or today.' }
        start: { type: string, description: 'A full timestamp (2026-10-15T09:30:00-04:00) or a 24-hour time (09:30) on date.' }
        end: { type: string, description: 'Same form as start. A clock time before start means past midnight.' }
        hours: { type: number, description: Length in hours when there are no start and end. }
        title: { type: string, maxLength: 200 }
        note: { type: string, maxLength: 2000 }
        billable: { type: boolean, default: true }
        activity: { $ref: '#/components/schemas/Activity' }
        ticket: { $ref: '#/components/schemas/Ticket' }

    SessionUpdate:
      type: object
      additionalProperties: false
      properties:
        client: { type: string }
        date: { type: string, format: date }
        start: { type: string }
        end: { type: string }
        hours: { type: number }
        title: { type: [string, 'null'], maxLength: 200 }
        note: { type: [string, 'null'], maxLength: 2000 }
        billable: { type: boolean }
        activity: { $ref: '#/components/schemas/Activity' }
        ticket: { oneOf: [{ $ref: '#/components/schemas/Ticket' }, { type: 'null' }] }

    ClientSettings:
      type: object
      properties:
        hourly_rate: { type: number, minimum: 0 }
        budget_hours: { type: [number, 'null'], minimum: 0, description: The budget in hours, when budget_kind is hours. }
        budget_kind: { type: string, enum: [hours, money], description: 'Which amount the budget uses: budget_hours or budget_amount.' }
        budget_amount: { type: [number, 'null'], minimum: 0, description: 'The budget in money, when budget_kind is money. Counts billable hours at each session''s rate, else the client''s.' }
        budget_period: { type: string, enum: [total, period], description: 'total covers the whole engagement; period resets each billing period.' }
        budget_starts_on: { type: [string, 'null'], format: date, description: A total budget counts from this day (all time when null). }
        billing_cycle: { type: string, enum: [monthly, weekly] }
        billing_start_day: { type: [integer, 'null'], minimum: 1, maximum: 28, description: Monthly billing starts on this day. }
        billing_weekday: { type: [integer, 'null'], minimum: 0, maximum: 6, description: 'Weekly billing starts on this day (0 is Sunday).' }
        email: { type: [string, 'null'], format: email }

    Client:
      type: object
      properties:
        client: { type: string }
        hourly_rate: { type: [number, 'null'] }
        billing_cycle: { type: [string, 'null'] }
        billing_start_day: { type: [integer, 'null'] }
        billing_weekday: { type: [integer, 'null'] }
        email: { type: [string, 'null'] }
        budget_hours: { type: [number, 'null'] }
        budget_kind: { type: [string, 'null'], enum: [hours, money, null] }
        budget_amount: { type: [number, 'null'] }
        budget_period: { type: [string, 'null'], enum: [total, period, null] }
        budget_starts_on: { type: [string, 'null'], format: date }
        budget:
          description: Where the budget stands today, or null without one. A team client's budget counts every member's time.
          oneOf:
            - type: 'null'
            - type: object
              properties:
                kind: { type: string, enum: [hours, money] }
                limit: { type: number }
                used: { type: number }
                remaining: { type: number, description: Negative when over budget. }
                percent: { type: integer, description: Rounded down. }
                alert_reached: { type: [integer, 'null'], enum: [80, 100, null], description: The highest alert threshold passed. }
                resets: { type: string, enum: [never, billing_period] }
                from: { type: [string, 'null'], format: date }
                to: { type: [string, 'null'], format: date }
                counts_team: { type: boolean }
                summary: { type: string, example: '32 of 40 h, 80%' }
        hours_all_time: { type: number }
        hours_this_month: { type: number }
        budget_used_percent: { type: [integer, 'null'], description: 'All-time hours against budget_hours. Older field; budget.percent follows the budget''s kind and period.' }
        last_worked: { type: [string, 'null'], format: date }

    TimerFields:
      type: object
      additionalProperties: false
      properties:
        client: { type: string, description: 'One of your clients, or "" for none yet.' }
        title: { type: [string, 'null'], maxLength: 200 }
        note: { type: [string, 'null'], maxLength: 2000 }
        activity: { $ref: '#/components/schemas/Activity' }
        ticket: { oneOf: [{ $ref: '#/components/schemas/Ticket' }, { type: 'null' }] }

    Timer:
      type: object
      properties:
        id: { type: string, format: uuid, description: New for every timer started. }
        state: { type: string, enum: [running, paused] }
        client: { type: [string, 'null'] }
        title: { type: [string, 'null'] }
        note: { type: [string, 'null'] }
        activity: { $ref: '#/components/schemas/Activity' }
        ticket: { oneOf: [{ $ref: '#/components/schemas/Ticket' }, { type: 'null' }] }
        elapsed_seconds: { type: integer, description: 'Time worked so far, pauses left out.' }
        started_at: { type: [string, 'null'], format: date-time, description: 'While running: now minus the time worked.' }
        updated_at: { type: string, format: date-time }
        changed_by: { type: [string, 'null'], description: 'The device that made the last change: web, ios, mac, api ...' }

    TimerResponse:
      type: object
      properties:
        timer: { oneOf: [{ $ref: '#/components/schemas/Timer' }, { type: 'null' }] }

    Summary:
      type: object
      properties:
        range: { type: object, properties: { from: { type: string, format: date }, to: { type: string, format: date } } }
        currency: { type: string }
        weekly_goal_hours: { type: [number, 'null'] }
        total_hours: { type: number }
        billable_hours: { type: number }
        non_billable_hours: { type: number }
        estimated_earnings: { type: number }
        sessions: { type: integer }
        by_client:
          type: array
          items: { type: object, properties: { client: { type: string }, hours: { type: number }, billable_hours: { type: number }, earnings: { type: number }, sessions: { type: integer } } }
        by_ticket:
          type: array
          items: { type: object, properties: { ticket: { type: string }, client: { type: string }, hours: { type: number }, sessions: { type: integer }, url: { type: [string, 'null'] } } }
        by_activity:
          type: array
          items: { type: object, properties: { activity: { type: string }, hours: { type: number } } }

    WebhookEventType:
      type: string
      enum: [session.created, session.updated, session.deleted, timer.started, timer.paused, timer.resumed, timer.stopped]

    Webhook:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string }
        events: { type: array, items: { $ref: '#/components/schemas/WebhookEventType' } }
        description: { type: [string, 'null'] }
        status:
          type: string
          enum: [active, failing, 'off']
          description: 'failing: a delivery used up its retries, so no new events are sent until it is turned back on in Tally.'
        failing_since: { type: [string, 'null'], format: date-time }
        last_delivery_at: { type: [string, 'null'], format: date-time, description: 'The last attempt, delivered or not.' }
        last_status: { type: [integer, 'null'], description: The HTTP status your URL answered last time. }
        created_at: { type: string, format: date-time }

    WebhookDelivery:
      type: object
      required: [id, type, created_at, data]
      properties:
        id: { type: string, format: uuid, description: The event id. A retry has the same id. }
        type: { $ref: '#/components/schemas/WebhookEventType' }
        created_at: { type: string, format: date-time, description: When it happened. }
        data: { type: object }
