{
  "openapi": "3.1.0",
  "info": {
    "title": "Tallymeter API",
    "version": "1.0.0",
    "description": "Token-authenticated API for agents and scripts. Create a personal access token at Settings → API tokens; tokens are scoped (`timer`, `projects:read`, `entries:write`, `reports:read`). All timestamps are ISO-8601 UTC. Errors are JSON with a `message` field. Rate limit: 120 requests/minute per user. Tallymeter is also a native MCP server at /mcp (Streamable HTTP, same Bearer tokens) with tools to start/stop the timer, log completed work and read unbilled totals. Access: free 30-day trial (no card), then a paid plan (€9/month or €90/year); API/MCP is included in every plan, and a lapsed plan returns 403 with a renewal link on every call."
  },
  "servers": [
    {
      "url": "https://tallymeter.com"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/api/v1/timer": {
      "get": {
        "summary": "Read the current timer state",
        "description": "Returns whether a timer is running and, if so, the running entry. Requires the `timer` scope.",
        "operationId": "getTimer",
        "responses": {
          "200": {
            "description": "Current timer state",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "running": {
                      "type": "boolean"
                    },
                    "entry": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/TimeEntry"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    }
                  },
                  "required": [
                    "running",
                    "entry"
                  ]
                },
                "examples": {
                  "idle": {
                    "value": {
                      "running": false,
                      "entry": null
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/v1/timer/start": {
      "post": {
        "summary": "Start a timer",
        "description": "Starts a new timer. Any currently running timer is stopped first (only one timer runs at a time). Requires the `timer` scope.",
        "operationId": "startTimer",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 1000,
                    "description": "What is being worked on, e.g. a ticket key plus summary.",
                    "examples": [
                      "ABC-1 fix login"
                    ]
                  },
                  "project_id": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "One of your project ids from GET /api/v1/projects."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Timer started",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "running": {
                      "type": "boolean",
                      "const": true
                    },
                    "entry": {
                      "$ref": "#/components/schemas/TimeEntry"
                    }
                  },
                  "required": [
                    "running",
                    "entry"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/v1/timer/stop": {
      "post": {
        "summary": "Stop the running timer",
        "description": "Stops the running timer and returns the stopped entry. Idempotent: when no timer is running this still returns 200 with `stopped: null`, so retries are safe. Requires the `timer` scope.",
        "operationId": "stopTimer",
        "responses": {
          "200": {
            "description": "Timer stopped, or nothing was running",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "running": {
                      "type": "boolean",
                      "const": false
                    },
                    "stopped": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/TimeEntry"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "message": {
                      "type": "string",
                      "description": "Only present when no timer was running."
                    }
                  },
                  "required": [
                    "running",
                    "stopped"
                  ]
                },
                "examples": {
                  "nothingRunning": {
                    "value": {
                      "running": false,
                      "stopped": null,
                      "message": "No timer was running."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/v1/projects": {
      "get": {
        "summary": "List your active projects",
        "description": "Returns the authenticated user's active projects, for picking a `project_id` when starting a timer. Requires the `projects:read` scope.",
        "operationId": "listProjects",
        "responses": {
          "200": {
            "description": "Active projects",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "projects": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Project"
                      }
                    }
                  },
                  "required": [
                    "projects"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/v1/entries/{entry}": {
      "patch": {
        "summary": "Update an entry's description or project",
        "description": "Rewrites the description and/or project of one of your entries — running or finished — without touching its start/end times. Made for the \"timer started, described later\" flow: no need to stop or restart anything. Entries already billed on an invoice are frozen and return 409. Requires the `entries:write` scope.",
        "operationId": "updateEntry",
        "parameters": [
          {
            "name": "entry",
            "in": "path",
            "required": true,
            "description": "Entry id, e.g. from the timer endpoints.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "properties": {
                  "description": {
                    "type": "string",
                    "maxLength": 1000,
                    "description": "New description. Omit to keep the current one; cannot be cleared over the API.",
                    "examples": [
                      "ABC-1 fix login"
                    ]
                  },
                  "project_id": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "New project id from GET /api/v1/projects; null detaches the entry from any project. Omit to keep the current project."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Entry updated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "updated": {
                      "type": "boolean",
                      "const": true
                    },
                    "entry": {
                      "$ref": "#/components/schemas/TimeEntry"
                    }
                  },
                  "required": [
                    "updated",
                    "entry"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No entry with this id on the token's account",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Message"
                }
              }
            }
          },
          "409": {
            "description": "Entry is already billed on an invoice — billed time is frozen and can only be edited in the web app",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Message"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/v1/feedback": {
      "post": {
        "summary": "Send feedback from an agent",
        "description": "Stores feedback for the developers: a capability you looked for and could not find, a bug, confusing docs, or anything else. Works with any valid token — no specific scope required. Every report is read.",
        "operationId": "sendFeedback",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "message": {
                    "type": "string",
                    "maxLength": 2000,
                    "description": "The feedback itself — what you looked for, what went wrong, or what was unclear."
                  },
                  "category": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "bug",
                      "missing-capability",
                      "docs",
                      "other",
                      null
                    ],
                    "description": "Use missing-capability when you looked for an endpoint, tool or field that does not exist."
                  },
                  "tool": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 50,
                    "description": "The endpoint or MCP tool the feedback relates to, if any."
                  }
                },
                "required": [
                  "message"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Feedback stored",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "received": {
                      "type": "boolean"
                    },
                    "id": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "received",
                    "id"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/api/v1/tickets/{key}/summary": {
      "get": {
        "summary": "Tracked vs unbilled totals for one ticket",
        "description": "Sums every finished entry whose description carries the ticket key (e.g. `ABC-1 fix login`) — the same convention the Jira sync reconciles on. `tracked_*` covers all finished time; `unbilled_*` only entries not yet on an invoice. Requires the `reports:read` scope.",
        "operationId": "getTicketSummary",
        "parameters": [
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "Jira-style ticket key, e.g. ABC-1 (case-insensitive).",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z][A-Za-z0-9]+-[0-9]+$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Per-ticket totals",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "key": {
                      "type": "string"
                    },
                    "tracked_seconds": {
                      "type": "integer"
                    },
                    "tracked_hours": {
                      "type": "number"
                    },
                    "unbilled_seconds": {
                      "type": "integer"
                    },
                    "unbilled_hours": {
                      "type": "number"
                    },
                    "unbilled_amount": {
                      "type": "number",
                      "description": "unbilled time × the applicable hourly rate"
                    }
                  },
                  "required": [
                    "key",
                    "tracked_seconds",
                    "tracked_hours",
                    "unbilled_seconds",
                    "unbilled_hours",
                    "unbilled_amount"
                  ]
                },
                "examples": {
                  "tracked": {
                    "value": {
                      "key": "ABC-1",
                      "tracked_seconds": 5400,
                      "tracked_hours": 1.5,
                      "unbilled_seconds": 1800,
                      "unbilled_hours": 0.5,
                      "unbilled_amount": 25.0
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/api/v1/me": {
      "get": {
        "summary": "Identity of the token's owner",
        "description": "Returns the id, name and email of the account the token belongs to. Works with any valid token (no scope required) — used by OAuth clients to label the connected account.",
        "operationId": "getMe",
        "responses": {
          "200": {
            "description": "Token owner",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    },
                    "email": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id",
                    "name",
                    "email"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Personal access token created at Settings → API tokens. Scopes: `timer` (timer endpoints), `projects:read` (projects endpoint), `entries:write` (PATCH /api/v1/entries/{entry} and MCP tools log-time / update-entry), `reports:read` (ticket summaries and MCP tool unbilled-summary). POST /api/v1/feedback accepts any valid token, no scope required."
      }
    },
    "schemas": {
      "Message": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          }
        },
        "required": [
          "message"
        ]
      },
      "TimeEntry": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "project_id": {
            "type": [
              "integer",
              "null"
            ]
          },
          "project_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "started_at": {
            "type": "string",
            "format": "date-time",
            "description": "ISO-8601 UTC"
          },
          "ended_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO-8601 UTC; null while running"
          },
          "duration_seconds": {
            "type": "integer"
          },
          "is_running": {
            "type": "boolean"
          },
          "source": {
            "type": "string",
            "enum": [
              "web",
              "api",
              "mcp"
            ],
            "description": "Surface that created the entry"
          },
          "agent": {
            "type": [
              "string",
              "null"
            ],
            "description": "Name of the API token (= agent) that created the entry; null for web-created entries"
          }
        },
        "required": [
          "id",
          "description",
          "project_id",
          "project_name",
          "started_at",
          "ended_at",
          "duration_seconds",
          "is_running",
          "source",
          "agent"
        ]
      },
      "Project": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "color": {
            "type": "string",
            "description": "Hex color, e.g. #10b981"
          }
        },
        "required": [
          "id",
          "name",
          "color"
        ]
      }
    },
    "responses": {
      "Unauthenticated": {
        "description": "Missing or invalid token",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Message"
            },
            "examples": {
              "unauthenticated": {
                "value": {
                  "message": "Unauthenticated."
                }
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Token lacks the required scope, or the account has no active plan",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Message"
            }
          }
        }
      },
      "ValidationError": {
        "description": "Validation failed",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "message": {
                  "type": "string"
                },
                "errors": {
                  "type": "object",
                  "additionalProperties": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              },
              "required": [
                "message",
                "errors"
              ]
            }
          }
        }
      }
    }
  }
}