Getting started
The Wolovy API lets you read your clients, projects and hours from other systems, keep each project’s maintenance block up to date and publish reports to the Client Portal. It works with a company API key.
- Create an API key. In Wolovy, open Company tools (
/company/tools) and go to the API keys tab. Only thesuper_adminandadminroles can do this. - Send it with every request in the
X-Company-Tokenheader. - Call the API from your server, not from the browser: the API does not accept requests from web origins that haven’t been allowed.
curl "https://app.wolovy.com/api/clients?companyId=<COMPANY_ID>&page=1&pageSize=20" \
-H "X-Company-Token: <TOKEN>"
Authentication
Each company can have up to 20 API keys, each with a label saying which application uses it. They are all valid at the same time and are revoked separately. Regenerating or revoking a key invalidates its previous value immediately.
- The key always goes in the
X-Company-Token: <TOKEN>header. Authorization: Beareris for user sessions and does not work with API keys.- Every key belongs to one company. If you send a
companyId(in the query) or anX-Company-Idheader for another company, the response is403 api_token_company_denied.
Treat every key like a password: one per integration, stored on your server and never in the code of a website or an app.
What a key can do
API keys are read-only: they accept GET, HEAD and OPTIONS. There are only five allowed writes:
| Method | Path | What for |
|---|---|---|
PATCH |
/projects/{id}/maintenance |
Keep a project’s maintenance block up to date |
POST |
/client-portals/{id}/entries |
Create a report in a Client Portal |
PATCH |
/client-portals/{id}/entries/reorder |
Reorder the reports |
PATCH |
/client-portals/{id}/entries/{entryId} |
Edit a report |
POST |
/client-portals/{id}/entries/{entryId}/publish |
Publish a report |
Any other write returns 403 api_token_read_only.
These are not available with a key either, not even for reading: Project Management (/pm), Tickets, Economic Health, API key management and Client Portal reads. They all need a user session.
Conventions
- Base URL:
https://app.wolovy.com/api - Format: JSON. For writes, send
Content-Type: application/json. - Dates: ISO 8601.
YYYY-MM-DDin filters andYYYY-MM-DDTHH:mm:ss.sssZin timestamps. - Pagination:
pageandpageSize. Paginated lists return{ total, items, page, pageSize }. - Parameters: only send the documented ones.
Rate limit
Up to 200 requests per minute per IP address. Every response carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset. Go over it and the response is a 429 with a retry-after header telling you when to try again.
Errors
Application errors arrive as { "message": "…", "code": "…" } (code isn’t always present). Server errors, such as a 429 or malformed JSON, arrive as { "statusCode", "error", "message" }.
| Code | When |
|---|---|
400 |
Invalid body or parameters. For example, api_token_user_id_required. |
401 |
The key is missing or invalid. |
403 |
api_token_read_only, api_token_company_denied, api_token_not_allowed or api_token_user_denied. |
404 |
The resource doesn’t exist. |
413 |
A report’s HTML is larger than 512 KB. |
422 |
A report component isn’t valid. The response includes issues. |
429 |
Rate limit exceeded. |
Clients
List clients
GET /clients?companyId={uuid}&page=1&pageSize=20&search=
Paginated list of the company’s clients. Response: { total, items, page, pageSize }.
curl "https://app.wolovy.com/api/clients?companyId=<COMPANY_ID>&search=brava" \
-H "X-Company-Token: <TOKEN>"
Get a client
GET /clients/{id}
The client’s detail, with its notes.
Projects
List projects
GET /projects?companyId={uuid}&page=1&pageSize=20&search=&status=&priority=&clientId=&maintenance=&dateRange=&from=&to=
Lists and filters the company’s projects.
Get a project
GET /projects/{id}
The full detail: client, assignments, times and subprojects. It includes durationBreakdown: { direct, subtasks, projectManagement } with the hours logged directly on the project, on its jobs (subtasks in the API) and on its Project Management tasks. totalDurationSeconds adds them all up.
Subprojects
GET /projects/{id}/subprojects
Update the maintenance block
PATCH /projects/{id}/maintenance
Updates only the project’s maintenance block. It is one of the five writes allowed with an API key. It takes a partial body with any of these fields: maintenance (boolean), maintenanceService and one field per month, from maintenanceJanuary to maintenanceDecember.
- Only the keys you send are written; the rest stay as they were.
nullor an empty string clears that field.- Any other project field, or an empty body, returns
400. - Response:
{ applied: string[], serverValues: {…}, updatedAt }.
This example only touches January and March; the other ten months and maintenanceService stay the same:
curl -X PATCH "https://app.wolovy.com/api/projects/<PROJECT_ID>/maintenance" \
-H "Content-Type: application/json" \
-H "X-Company-Token: <TOKEN>" \
-d '{"maintenanceJanuary":"Annual review\n12 - Filter change","maintenanceMarch":null}'
And this one marks a project as a maintenance project:
curl -X PATCH "https://app.wolovy.com/api/projects/<PROJECT_ID>/maintenance" \
-H "Content-Type: application/json" \
-H "X-Company-Token: <TOKEN>" \
-d '{"maintenance":true,"maintenanceService":"Annual support contract"}'
Time
GET /time/my?userId={uuid}&date=YYYY-MM-DD
GET /time/my?userId={uuid}&from=YYYY-MM-DD&to=YYYY-MM-DD
A person’s timeline, for one day or a date range. With an API key, userId is required (if missing: 400 api_token_user_id_required) and must belong to the key’s company (if not: 403 api_token_user_denied). You can get the userId values from the assignments returned by GET /projects/{id}.
Statistics
GET /stats/dedication?companyId={uuid}&month=1-12&year=YYYY
Returns a summary with the total hours, the people and the projects for that month. companyId, month and year are required.
curl "https://app.wolovy.com/api/stats/dedication?companyId=<COMPANY_ID>&month=9&year=2026" \
-H "X-Company-Token: <TOKEN>"
Client Portal
With an API key you can create, edit, reorder and publish reports in the Client Portal. You can’t read portals: copy the portal’s ID from Wolovy, from the address of its page (/client-portals/{id}).
| Method | Path | Body |
|---|---|---|
POST |
/client-portals/{id}/entries |
{ title, html?, publish? } |
PATCH |
/client-portals/{id}/entries/reorder |
{ orderedIds: [uuid] } |
PATCH |
/client-portals/{id}/entries/{entryId} |
{ title?, html?, status? } |
POST |
/client-portals/{id}/entries/{entryId}/publish |
{ note? } |
POST /entries returns { entry, report, componentCount }. Keep entry.id: the other endpoints use it.
A report created through the API always stays a draft unless you send publish: true or call the publish endpoint. Every version created this way is labelled as created by the API.
curl -X POST "https://app.wolovy.com/api/client-portals/<PORTAL_ID>/entries" \
-H "Content-Type: application/json" \
-H "X-Company-Token: <TOKEN>" \
-d '{"title":"September 2026","html":"<h1>September</h1><p>This month we launched the new website.</p>"}'
Allowed HTML
HTML is cleaned when it’s saved and only the clean copy is served. It is also shown inside an isolated iframe, with no cookies, no storage and no network.
- Allowed tags:
p br hr h1…h6 strong b em i u s small sub sup mark code pre blockquote abbr time del ins ul ol li dl dt dd table thead tbody tfoot tr th td caption colgroup col figure figcaption img a div span section article header footer main aside. - Removed with their content:
script style noscript template textarea option title iframe object embed applet param base link meta form input button select label svg math canvas audio video source track slot portal marquee frame frameset dialog details summary. - Attributes:
class id title lang dir role style aria-*. Classes are only kept if they start withwv-. - Links: only
https:andmailto:. They always open in a new tab, withrel="noopener noreferrer nofollow ugc". - Images: only
data:image/…or images uploaded to the portal itself from Wolovy. Externalhttps://images are removed. - Inline styles: a closed list of properties (colour, background, typography, spacing, borders, flex and grid). Any value containing
url(,expression(,@import,\,<or/*is rejected. - JavaScript from the content never runs, and neither do attributes such as
onclickoronload. - Limits: 512 KB per document, 5,000 elements, 64 levels of nesting and 40 components.
Components
Content never runs JavaScript, so a chart is declared as an empty element with a single JSON attribute, and Wolovy draws it. Only div, section and figure can carry a component.
<div data-chart='{"type":"bar","title":"Visits","categories":["Jun","Jul","Aug","Sep"],
"series":[{"name":"Sessions","data":[1200,1500,1800,2200]}]}'></div>
<div data-counter='{"label":"Conversions","value":1234,"suffix":" units"}'></div>
<div data-progress='{"label":"Progress","value":72}'></div>
<div data-kpi='{"columns":3,"items":[
{"label":"Visits","value":18400},
{"label":"Conversions","value":312},
{"label":"Revenue","value":9450,"prefix":"EUR "}]}'></div>
<div data-comparison='{"aLabel":"2025","bLabel":"2026","rows":[
{"label":"Sessions","a":14000,"b":18400},
{"label":"Bounce (%)","a":58,"b":47,"higherIsBetter":false}]}'></div>
The JSON is validated on save: an unknown key is a 422 error, not ignored, so a typo shows up as an error instead of an empty chart. At most 8 KB of JSON per component and 40 components per report.
data-chart
| Field | Type | Notes |
|---|---|---|
type |
bar, stacked-bar, line, area or donut |
Required |
title |
Text, up to 80 characters | |
unit |
Text, up to 12 characters | Shown in the tooltips |
decimals |
0 to 4, default 0 |
|
legend |
Boolean, default true |
|
categories |
1 to 48 strings of up to 32 characters | The X axis |
series |
1 to 6 { name, color?, data: number[] } |
Every series[].data must have exactly as many values as categories. A donut takes a single series and its legend shows the categories.
data-counter
| Field | Type | Notes |
|---|---|---|
label |
Text, 1 to 48 characters | Required |
value |
Number | Required |
prefix |
Text, up to 4 characters | Spaces are kept: "EUR " gives EUR 9,450 |
suffix |
Text, up to 8 characters | Same: " units" gives 128 units |
decimals |
0 to 4, default 0 |
|
animate |
Boolean, default true |
Respects the reduced-motion preference |
tone |
See colours |
data-progress
| Field | Type | Notes |
|---|---|---|
value |
Number | Required |
max |
Number above 0, default 100 |
|
label |
Text, up to 48 characters | |
showValue |
Boolean, default true |
Shows the percentage |
tone |
See colours |
data-kpi
| Field | Type | Notes |
|---|---|---|
columns |
2, 3 or 4 |
From 640 px wide; below that the grid adapts |
items |
1 to 8 objects shaped like data-counter |
Required |
data-comparison
| Field | Type | Notes |
|---|---|---|
title |
Text, up to 120 characters | |
aLabel / bLabel |
Text, 1 to 24 characters | Required, they head the two columns |
format |
number, percent or currency, default number |
|
currency |
3-letter code | Only with format: "currency" |
decimals |
0 to 4, default 0 |
|
unit |
Text, up to 12 characters | |
higherIsBetter |
Boolean, default true |
Can be set row by row |
rows |
1 to 24 { label, a, b, higherIsBetter? } |
The Δ column turns green when the change is for the better and red when it’s for the worse. higherIsBetter is set per row because a report mixes metrics where going up is good (sessions) with others where going down is (bounce rate, load time).
Colours
tone and color only accept primary, success, warning, danger, muted or series. The actual colours come from the company’s settings, never from the HTML: that keeps every report consistent, and AI-generated content can’t break the visual identity.
AI-generated reports
- Copy the portal’s ID from its page in Wolovy.
- Read whatever data the report needs through the API:
GET /projects/{id},GET /time/my,GET /stats/dedication. - Create the report with
POST /client-portals/{id}/entries. It stays a draft. - Someone at the agency reviews it, publishes it and sends the notice to the client from Wolovy. If you trust your pipeline, you can also publish through the API.
Nothing gets published just because an AI wrote it.
Embedding the portal
Every portal also has an embed link: dashboard and reports, read-only and without tickets. It is generated on the portal’s page and only works on the domains you allow.
<iframe src="https://app.wolovy.com/api/client-portal/e/<EMBED_TOKEN>"
style="width:100%;min-height:800px;border:0"
loading="lazy" referrerpolicy="no-referrer"></iframe>
Good practice
- Create one key per integration, with a label saying which one it is.
- Keep it on your server. Never in the code of a website, an app or a repository.
- If you think it has leaked, regenerate it and update the integration.
- Always send an explicit
companyId, especially if your account runs several companies. - Stay within 200 requests per minute and, after a
429, wait for as long asretry-aftersays.