API Reference
Seven operations, and nothing to authenticate with — the space UUID in the path is the credential. For commands to paste rather than shapes to build against, see curl. The same surface is published as OpenAPI 3.1 at /openapi.json, if you would rather generate a client than write one.
Four things to know before you build on this
-
1
A write replaces the whole state
Omitting
valuesclears it rather than leaving it alone, andPATCHis refused with a 405 rather than quietly promising a merge. The one exception is a body of just{"done": true}, which closes the task and keeps the last numbers. -
2
progress: nullis not zeroIt means nothing has been reported, or what was reported has expired. A task at zero reports an object with
current: 0. -
3
A parent's progress is derived
With children, its own counts are ignored and
aggregatedis true. Never report against a parent. -
4
Completion is one-way
It fires the notification once. Reporting afterwards still overwrites progress, but nothing completes twice.
One task, start to finish
Every operation below, in the order you would call them. Read the loop and you have the model: a write carries the counts it knows, end may change between calls, and closing takes nothing but done.
progresswatch space new "Crawler"
TASK=$(progresswatch new "Crawl example.com")
for pages in 0 250 500 750; do
progresswatch update "$TASK" \
--current "$pages" --end 1000 --values pages="$pages"
done
progresswatch done "$TASK"
SPACE=$(curl -s -X POST "https://progress.watch/spaces" \
-H 'Content-Type: application/json' \
-d '{"title": "Crawler"}' | jq -r .uuid)
TASK=$(curl -s -X POST "https://progress.watch/spaces/$SPACE/tasks" \
-H 'Content-Type: application/json' \
-d '{"title": "Crawl example.com"}' | jq -r .uuid)
for pages in 0 250 500 750; do
curl -s -X PUT "https://progress.watch/tasks/$TASK" \
-H 'Content-Type: application/json' \
-d "{\"current\": $pages, \"end\": 1000}"
done
curl -s -X PUT "https://progress.watch/tasks/$TASK" \
-H 'Content-Type: application/json' -d '{"done": true}'
const send = (method, path, body) =>
fetch(`https://progress.watch${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then((response) => response.json())
const { uuid: space } = await send('POST', '/spaces', {
title: 'Crawler'
})
const { uuid: task } = await send(
'POST', `/spaces/${space}/tasks`, { title: 'Crawl example.com' }
)
for (let pages = 0; pages < 1000; pages += 250) {
await send('PUT', `/tasks/${task}`, {
current: pages,
end: 1000,
values: { pages }
})
}
await send('PUT', `/tasks/${task}`, { done: true })
import requests
server = "https://progress.watch"
space = requests.post(
f"{server}/spaces", json={"title": "Crawler"}
).json()["uuid"]
task = requests.post(
f"{server}/spaces/{space}/tasks",
json={"title": "Crawl example.com"},
).json()["uuid"]
for pages in range(0, 1000, 250):
requests.put(
f"{server}/tasks/{task}",
json={
"current": pages,
"end": 1000,
"values": {"pages": pages},
},
)
requests.put(f"{server}/tasks/{task}", json={"done": True})
use GuzzleHttp\Client;
$client = new Client(['base_uri' => 'https://progress.watch']);
$space = json_decode($client->post('/spaces', [
'json' => ['title' => 'Crawler'],
])->getBody())->uuid;
$task = json_decode($client->post("/spaces/{$space}/tasks", [
'json' => ['title' => 'Crawl example.com'],
])->getBody())->uuid;
for ($pages = 0; $pages < 1000; $pages += 250) {
$client->put("/tasks/{$task}", ['json' => [
'current' => $pages,
'end' => 1000,
'values' => ['pages' => $pages],
]]);
}
$client->put("/tasks/{$task}", ['json' => ['done' => true]]);
require 'faraday'
conn = Faraday.new('https://progress.watch') do |f|
f.request :json
f.response :json
end
space = conn.post('/spaces', { title: 'Crawler' }).body['uuid']
task = conn.post("/spaces/#{space}/tasks",
{ title: 'Crawl example.com' }).body['uuid']
0.step(750, 250) do |pages|
conn.put("/tasks/#{task}",
{ current: pages, end: 1000, values: { pages: } })
end
conn.put("/tasks/#{task}", { done: true })
var client = HttpClient.newHttpClient();
var mapper = new ObjectMapper();
var space = mapper.readTree(send(client, "POST", "/spaces", """
{"title": "Crawler"}""")).get("uuid").asText();
var task = mapper.readTree(send(client, "POST",
"/spaces/" + space + "/tasks", """
{"title": "Crawl example.com"}""")).get("uuid").asText();
for (var pages = 0; pages < 1000; pages += 250) {
send(client, "PUT", "/tasks/" + task, """
{"current": %d, "end": 1000}""".formatted(pages));
}
send(client, "PUT", "/tasks/" + task, """
{"done": true}""");
static String send(HttpClient client, String method,
String path, String body) throws Exception {
var request = HttpRequest.newBuilder()
.uri(URI.create("https://progress.watch" + path))
.header("Content-Type", "application/json")
.method(method, HttpRequest.BodyPublishers.ofString(body))
.build();
return client.send(request,
HttpResponse.BodyHandlers.ofString()).body();
}
using System.Net.Http.Json;
using System.Text.Json;
var client = new HttpClient
{
BaseAddress = new Uri("https://progress.watch")
};
var created = await client.PostAsJsonAsync("/spaces",
new { title = "Crawler" });
var space = (await created.Content.ReadFromJsonAsync<JsonElement>())
.GetProperty("uuid").GetString();
var opened = await client.PostAsJsonAsync($"/spaces/{space}/tasks",
new { title = "Crawl example.com" });
var task = (await opened.Content.ReadFromJsonAsync<JsonElement>())
.GetProperty("uuid").GetString();
for (var pages = 0; pages < 1000; pages += 250)
{
await client.PutAsJsonAsync($"/tasks/{task}",
new { current = pages, end = 1000, values = new { pages } });
}
await client.PutAsJsonAsync($"/tasks/{task}", new { done = true });
Create a space
post /spaces
The response carries the only copy of the uuid. Keep it like an API token.
Request body
- title string | null optional
- icon string optional
- One character; an emoji reads best.
Example
progresswatch space new "Production"
curl -X POST "https://progress.watch/spaces" \
-H 'Content-Type: application/json' \
-d '{"title": "Production", "icon": "🚀"}'
const response = await fetch('https://progress.watch/spaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Production', icon: '🚀' })
})
const { uuid } = await response.json()
import requests
space = requests.post(
"https://progress.watch/spaces",
json={"title": "Production", "icon": "🚀"},
).json()
use GuzzleHttp\Client;
$client = new Client(['base_uri' => 'https://progress.watch']);
$response = $client->post('/spaces', [
'json' => ['title' => 'Production', 'icon' => '🚀'],
]);
$space = json_decode((string) $response->getBody(), true);
require 'faraday'
conn = Faraday.new('https://progress.watch') do |f|
f.request :json
f.response :json
end
space = conn.post('/spaces', { title: 'Production', icon: '🚀' }).body
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://progress.watch/spaces"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("""
{"title": "Production", "icon": "🚀"}"""))
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
using System.Net.Http.Json;
var client = new HttpClient { BaseAddress = new Uri("https://progress.watch") };
var response = await client.PostAsJsonAsync("/spaces", new
{
title = "Production",
icon = "🚀"
});
var space = await response.Content.ReadFromJsonAsync<JsonElement>();
Responses
201 Created
- uuid string
- title string | null
- icon string | null
422 Icon was more than one character
- error string
Create a task
post /spaces/{space_uuid}/tasks
The returned uuid is the only handle on the task. Until something reports against it the task reads as waiting for data, which looks the same as a reporter that died — so write to it when the work starts, even with nothing to count.
Path parameters
- space_uuid string required
Request body
- title string optional
- source string optional
- What is reporting, e.g. crawler.py
- parent_uuid string optional
- A step of an existing task. One level only.
Example
TASK=$(progresswatch new "Crawl docs" --source crawler.py)
curl -X POST "https://progress.watch/spaces/$SPACE_UUID/tasks" \
-H 'Content-Type: application/json' \
-d '{"title": "Crawl docs", "source": "crawler.py"}'
const response = await fetch(`https://progress.watch/spaces/${spaceUuid}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Crawl docs', source: 'crawler.py' })
})
const { uuid } = await response.json()
import requests
task = requests.post(
f"https://progress.watch/spaces/{space_uuid}/tasks",
json={"title": "Crawl docs", "source": "crawler.py"},
).json()
use GuzzleHttp\Client;
$client = new Client(['base_uri' => 'https://progress.watch']);
$response = $client->post("/spaces/{$spaceUuid}/tasks", [
'json' => ['title' => 'Crawl docs', 'source' => 'crawler.py'],
]);
$task = json_decode((string) $response->getBody(), true);
require 'faraday'
conn = Faraday.new('https://progress.watch') do |f|
f.request :json
f.response :json
end
task = conn.post("/spaces/#{space_uuid}/tasks", { title: 'Crawl docs', source: 'crawler.py' }).body
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://progress.watch/spaces/" + spaceUuid + "/tasks"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("""
{"title": "Crawl docs", "source": "crawler.py"}"""))
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
using System.Net.Http.Json;
var client = new HttpClient { BaseAddress = new Uri("https://progress.watch") };
var response = await client.PostAsJsonAsync($"/spaces/{spaceUuid}/tasks", new
{
title = "Crawl docs",
source = "crawler.py"
});
var task = await response.Content.ReadFromJsonAsync<JsonElement>();
Responses
201 Created
- uuid string
404 No such space
- error string
422 parent_uuid is unknown, is already a child, or belongs to another space
- error string
Report progress
put /tasks/{task_uuid}
Replaces the whole state. Omitting `values` clears it. Completing is one-way: it happens when `current` reaches a positive `end` or when `done` is true, sends the notification once, and later writes do not move the finish time. A body of just `{"done": true}` is the exception to the overwrite: it closes the task and keeps the last numbers reported, so a finished task still shows what it counted.
Path parameters
- task_uuid string required
Request body
- current number optional
- A count, not a percentage.
- end number optional
- May change between calls. Omit it for a count with no total: `current` rises, `ratio` stays null, and the task reads as a counter rather than a bar.
- values object optional
- done boolean optional
- Finish the task. Send it alone to keep the last numbers; send it beside a count and the usual overwrite applies.
Example
progresswatch update $TASK --current 1200 --end 50000 --values pages=1200 --values errors=3
curl -X PUT "https://progress.watch/tasks/$TASK" \
-H 'Content-Type: application/json' \
-d '{"current": 1200, "end": 50000, "values": {"pages": 1200, "errors": 3}}'
await fetch(`https://progress.watch/tasks/${task}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current: 1200,
end: 50000,
values: { pages: 1200, errors: 3 }
})
})
import requests
requests.put(
f"https://progress.watch/tasks/{task}",
json={"current": 1200, "end": 50000, "values": {"pages": 1200, "errors": 3}},
)
use GuzzleHttp\Client;
$client = new Client(['base_uri' => 'https://progress.watch']);
$client->put("/tasks/{$task}", [
'json' => [
'current' => 1200,
'end' => 50000,
'values' => ['pages' => 1200, 'errors' => 3],
],
]);
require 'faraday'
conn = Faraday.new('https://progress.watch') { |f| f.request :json }
conn.put("/tasks/#{task}", { current: 1200, end: 50_000, values: { pages: 1200, errors: 3 } })
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://progress.watch/tasks/" + task))
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString("""
{"current": 1200, "end": 50000, "values": {"pages": 1200, "errors": 3}}"""))
.build();
client.send(request, HttpResponse.BodyHandlers.ofString());
using System.Net.Http.Json;
var client = new HttpClient { BaseAddress = new Uri("https://progress.watch") };
await client.PutAsJsonAsync($"/tasks/{task}", new
{
current = 1200,
end = 50000,
values = new { pages = 1200, errors = 3 }
});
Responses
200 The task as it now stands
- uuid string
- space_uuid string
- parent_uuid string | null
- title string | null
- source string | null
- created_at string
- finished_at string | null
- duration integer | null
- Seconds, set once when the task completes.
- progress Progress
- children Task[]
- One level only. A child is always empty here.
400 values was not a flat object of numbers, strings or booleans
- error string
404 No such task
- error string
Report progress from a URL
get /tasks/{task_uuid}/report
The same write as PUT, reachable by anything that can only fire a URL: an uptime pinger, a webhook field in somebody else's product, a device, a cron line with a bare curl. If your client can send a request body, use PUT instead. This one is a GET that writes, so anything that follows the URL performs the write — a link unfurler in a chat app will report on your behalf. Keep it out of anywhere a machine might click it.
Path parameters
- task_uuid string required
- current number
- A count, not a percentage.
- end number
- May change between calls. Omit it for a count with no total.
- done boolean
- Finish the task. Alone, it keeps the last numbers reported. It also finishes on its own once current reaches end.
- values object
- Flat extras, as values[pages]=1200. They arrive as text, since a query string carries no types.
Example
curl -g "https://progress.watch/tasks/$TASK/report?current=1200&end=50000&values[errors]=3"
Responses
200 The task as it now stands
- uuid string
- space_uuid string
- parent_uuid string | null
- title string | null
- source string | null
- created_at string
- finished_at string | null
- duration integer | null
- Seconds, set once when the task completes.
- progress Progress
- children Task[]
- One level only. A child is always empty here.
400 values was not a flat object of numbers, strings or booleans
- error string
404 No such task
- error string
Read a space and every task in it
get /spaces/{space_uuid}
Returns every task by default. Tasks and their children come back in creation order, oldest first; any other order is a display decision and belongs to the client. A space that has run for months is worth paging through — see the parameters below.
Path parameters
- space_uuid string required
- limit integer
- How many top-level tasks to return. Counts back from the newest, so a bare limit gives the recent end of the space rather than its oldest rows. Children never count against it and are never cut off.
- before string
- Only tasks created strictly before this instant, newest first, for walking back through history. Pass the created_at of the oldest task you hold; it round-trips exactly and is never returned again.
- after string
- Only tasks created strictly after this instant, oldest first, for asking what is new. Pass the created_at of the newest task you hold.
- state string
- Only tasks that are still running, or only those that have finished. Omit it for both. Ask for active without a limit and finished with one: a task that has run for a week is otherwise lost behind a page of things that finished since.
Example
progresswatch list --json
curl "https://progress.watch/spaces/$SPACE_UUID"
const space = await fetch(`https://progress.watch/spaces/${spaceUuid}`).then((response) => response.json())
import requests
space = requests.get(f"https://progress.watch/spaces/{space_uuid}").json()
use GuzzleHttp\Client;
$client = new Client(['base_uri' => 'https://progress.watch']);
$space = json_decode((string) $client->get("/spaces/{$spaceUuid}")->getBody(), true);
require 'faraday'
conn = Faraday.new('https://progress.watch') { |f| f.response :json }
space = conn.get("/spaces/#{space_uuid}").body
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://progress.watch/spaces/" + spaceUuid))
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
using System.Net.Http.Json;
var client = new HttpClient { BaseAddress = new Uri("https://progress.watch") };
var space = await client.GetFromJsonAsync<JsonElement>($"/spaces/{spaceUuid}");
Responses
200 The space
- uuid string
- title string | null
- icon string | null
- One character. Counted in grapheme clusters.
- tasks Task[]
- Top-level tasks, each with its children nested.
400 A cursor that is not a timestamp, a limit that is not a whole number, or a state that is neither active nor finished
- error string
404 No such space, or the uuid is wrong
- error string
Read one task and its children
get /tasks/{task_uuid}
The same shape as one entry in a space, steps nested. Cheaper to poll than the whole space when only one thing is running.
Path parameters
- task_uuid string required
Example
progresswatch show $TASK --json
curl "https://progress.watch/tasks/$TASK"
const task = await fetch(`https://progress.watch/tasks/${taskUuid}`).then((response) => response.json())
import requests
task = requests.get(f"https://progress.watch/tasks/{task_uuid}").json()
use GuzzleHttp\Client;
$client = new Client(['base_uri' => 'https://progress.watch']);
$task = json_decode((string) $client->get("/tasks/{$taskUuid}")->getBody(), true);
require 'faraday'
conn = Faraday.new('https://progress.watch') { |f| f.response :json }
task = conn.get("/tasks/#{task_uuid}").body
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://progress.watch/tasks/" + taskUuid))
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
using System.Net.Http.Json;
var client = new HttpClient { BaseAddress = new Uri("https://progress.watch") };
var task = await client.GetFromJsonAsync<JsonElement>($"/tasks/{taskUuid}");
Responses
200 The task
- uuid string
- space_uuid string
- parent_uuid string | null
- title string | null
- source string | null
- created_at string
- finished_at string | null
- duration integer | null
- Seconds, set once when the task completes.
- progress Progress
- children Task[]
- One level only. A child is always empty here.
404 No such task
- error string
Health check
get /up
Checks its dependencies, so a container with a dead Redis fails it. The worker is reported too, and a dead one does not fail the check.
Example
progresswatch status
curl "https://progress.watch/up"
const health = await fetch('https://progress.watch/up').then((response) => response.json())
import requests
health = requests.get("https://progress.watch/up").json()
use GuzzleHttp\Client;
$health = json_decode((string) (new Client())->get('https://progress.watch/up')->getBody(), true);
require 'faraday'
health = Faraday.new { |f| f.response :json }.get('https://progress.watch/up').body
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder().uri(URI.create("https://progress.watch/up")).build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
using System.Net.Http.Json;
var client = new HttpClient();
var health = await client.GetFromJsonAsync<JsonElement>("https://progress.watch/up");
Responses
200 Healthy
503 A dependency is down