Hub API
A REST API over the backlog: read the reports a widget collected, create tasks, edit them, move them between columns. Everything answers JSON, everything is authenticated by one bearer token.
Base address:
https://hub.doctorweedy.com/api/v1
Authentication
Every call carries the key as a bearer token. Keys are issued in the cabinet under "API keys", shown once and stored hashed; each one has its own permissions, may be limited to certain projects, may expire, and can be revoked at any moment.
Authorization: Bearer hub_ak_…
Server-side only. An account key carries every right you granted it, so it belongs in your backend environment, never in page markup or a bundled script. Cross-origin browser calls to this API are deliberately not allowed — only the report intake answers them.
Three keys, three jobs
Mixing them up is the usual first stumble: neither key on a project's General tab can read the backlog.
| Key | Where to get it | What it does |
|---|---|---|
pk_… |
Project → General | Lets the widget file reports. Public — it sits in the page source. |
sk_… |
Project → General | Signs a reporter identity on your server. Nothing else. |
hub_ak_… |
Cabinet → API keys | Reads and edits the backlog. This is the one this page is about. |
Endpoints
| GET | /me | Who this key is: account, permissions, visible projects. Start here. |
| GET | /projects | Projects this key may see, with task counts. |
| GET | /context/{project} | Statuses, types, labels, areas and people in one payload. |
| GET | /tasks | The backlog. Filters: status, type, label, source, since, limit. |
| GET | /tasks/{id} | One task with its screenshots and diagnostics. |
| POST | /tasks | Create a task. Requires project and title. |
| PATCH | /tasks/{id} | Edit title, description, type, priority, label, area. |
| POST | /tasks/{id}/move | Change status — and, optionally, the order inside it. |
| DELETE | /tasks/{id} | Delete a task. |
| GET | /people | Reporters, most active first. |
A key limited to certain projects sees only those; a read-only key is refused every write.
Filtering the list
| Parameter | Values | Effect |
|---|---|---|
| project | project slug | Only this project. |
| status | backlog · in_progress · review · done | Only this column. |
| type | bug · idea · question · task | Only this kind of task. |
| label | string | Only tasks carrying this label. |
| source | widget · manual · import | Where the task came from. |
| since | date or ISO 8601 | Created no earlier than this. |
| limit | 1–500, default 200 | How many tasks to return. The summary still counts them all. |
What a list answers
GET https://hub.doctorweedy.com/api/v1/tasks?project=your-project
{
"tasks": [
{
"id": 1,
"project": "your-project",
"project_name": "Your Project",
"project_url": "https://example.com",
"title": "Checkout is broken",
"description": "Pressed pay, nothing happened",
"status": "backlog",
"type": "bug",
"priority": "normal",
"label": "bug",
"area": null,
"assignee": null,
"reporter_person": { "name": "Marina", "email": "marina@example.com", "verified": true },
"identity_verified": true,
"locale": "en",
"files": [],
"source": "widget",
"reporter": { "url": "https://example.com/checkout", "viewport": "1512x731", "user_agent": "…" },
"diagnostics": { "console": [ … ], "network": [ … ], "steps": [ … ] },
"screenshots": [ "https://…/shot.png" ],
"created_at": "2026-07-24T09:12:44+00:00",
"updated_at": "2026-07-24T09:12:44+00:00"
}
],
"meta": { "total": 42, "returned": 20, "limit": 20, "by_status": { "backlog": 30, "done": 12 } }
}
The summary is counted off the same filters but before the limit, so a caller drawing badges knows how many tasks there really are without asking twice.
Fields of a task
| Field | Type | Access |
|---|---|---|
| id | number | read |
| project, project_name, project_url | string | read; project is what writes address |
| title | string | read, write |
| description | string | read, write |
| status | backlog · in_progress · review · done | read; write via /move |
| type | bug · idea · question · task | read, write |
| priority | low · normal · high · urgent | read, write |
| label, area | string | read, write |
| assignee | string | read |
| reporter_person | object | read — who filed it, if known |
| identity_verified | boolean | read — was the identity signed with the secret key |
| locale | string | read — the page language the report came from |
| files | array of strings | read, write |
| screenshots | array of URLs | read |
| diagnostics | object | read — console, network, steps |
| reporter | object | read — URL, viewport, user agent |
| source | widget · manual · import | read |
| created_at, updated_at | ISO 8601 | read |
Status is the one field PATCH will not touch: moving a task also decides where it lands in its column, so it has its own call.
Answer codes
| 200 · 201 | Done. A write answers with the task it wrote. |
| 401 | No key, an unknown key, a revoked key or an expired one. |
| 403 | The key is valid but lacks the permission — a read-only key attempting a write, or a project it was not granted. |
| 404 | No such task, or it belongs to another workspace. The two are deliberately indistinguishable. |
| 422 | The body failed validation. The response names the fields. |
Check it from a terminal
# 1. Is the key alive and what does it see?
curl -s -H "Authorization: Bearer $HUB_API_KEY" https://hub.doctorweedy.com/api/v1/me
# 2. The backlog of one project
curl -s -H "Authorization: Bearer $HUB_API_KEY" "https://hub.doctorweedy.com/api/v1/tasks?project=your-project&status=backlog"
# 3. Create a task
curl -s -X POST -H "Authorization: Bearer $HUB_API_KEY" -H "Content-Type: application/json" \
-d '{"project":"your-project","title":"Checkout is broken","description":"Steps…","type":"bug"}' \
https://hub.doctorweedy.com/api/v1/tasks
# 4. Rename it, then move it to done
curl -s -X PATCH -H "Authorization: Bearer $HUB_API_KEY" -H "Content-Type: application/json" \
-d '{"title":"Checkout fails on card payment"}' https://hub.doctorweedy.com/api/v1/tasks/123
curl -s -X POST -H "Authorization: Bearer $HUB_API_KEY" -H "Content-Type: application/json" \
-d '{"status":"done"}' https://hub.doctorweedy.com/api/v1/tasks/123/move
A client for your project
Laravel example. Any HTTP client will do — the shape is the same.
// config/services.php
'hub' => [
'url' => env('HUB_URL'),
'key' => env('HUB_API_KEY'),
'project' => env('HUB_PROJECT'),
],
// app/Support/Hub.php
class Hub
{
protected function http(): PendingRequest
{
return Http::withToken(config('services.hub.key'))
->baseUrl(config('services.hub.url') . '/api/v1')
->acceptJson()
->timeout(10);
}
/** @return array<int,array<string,mixed>> */
public function tasks(?string $status = null): array
{
return $this->http()->get('/tasks', array_filter([
'project' => config('services.hub.project'),
'status' => $status,
]))->throw()->json('tasks');
}
public function create(string $title, string $description = ''): array
{
return $this->http()->post('/tasks', [
'project' => config('services.hub.project'),
'title' => $title,
'description' => $description,
])->throw()->json('task');
}
public function move(int $id, string $status): void
{
$this->http()->post("/tasks/{$id}/move", ['status' => $status])->throw();
}
}
The same in JavaScript:
const hub = async (path, init = {}) => {
const response = await fetch(`${process.env.HUB_URL}/api/v1${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.HUB_API_KEY}`,
'Content-Type': 'application/json',
...init.headers,
},
});
if (!response.ok) throw new Error(`Hub ${response.status}: ${await response.text()}`);
return response.json();
};
const { tasks, meta } = await hub('/tasks?project=your-project&status=backlog');
await hub('/tasks', { method: 'POST', body: JSON.stringify({ project: 'your-project', title: 'Filed by an agent' }) });
Then render them wherever you like
{{-- resources/views/backlog.blade.php --}}
@foreach (app(Hub::class)->tasks('backlog') as $task)
<article>
<h3>{{ $task['title'] }}</h3>
<p>{{ $task['description'] }}</p>
<small>{{ $task['project_name'] }} · {{ $task['status'] }} · {{ $task['type'] }}</small>
@foreach ($task['screenshots'] as $url)
<img src="{{ $url }}" alt="">
@endforeach
</article>
@endforeach
Screenshot URLs are absolute and public — you can put them straight in an img tag.
Webhooks: getting a report pushed to you
The API above is a pull: you ask, Hub answers. A webhook is the other direction — add one on the project's "Notifications" tab and every new report arrives at your URL as it happens.
What arrives: a POST with these two headers and a JSON body.
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Hub-Event: report.created
X-Hub-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015…
{
"event": "report.created",
"sent_at": "2026-07-28T10:15:00+00:00",
"task": { "id": 48, "project": "langeater", "title": "…", "screenshots": ["…"] }
}
The task object has exactly the fields listed above — the same shape GET /tasks returns.
Check the signature before you trust the body
The signature is HMAC-SHA256 of the RAW request body with the channel's secret. Compute it over the bytes you received, not over a re-encoded copy of the parsed JSON — re-encoding changes spacing and the signature stops matching.
// PHP
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_HUB_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
// Node
const crypto = require('crypto');
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) return res.sendStatus(401);
Delivery rules
- Answer with any 2xx. Anything else counts as a failure.
- Three attempts, half a second apart, then it gives up and the error is shown on the channel in the cabinet.
- Ten second timeout — do the slow work after you answer, not before.
- Leave the secret empty and one is generated for you; without a secret the signature header is simply absent.
- By default a channel only fires for reports from visitors the site could not identify. Switch it to "every report" if you want your own testing through as well.
Getting a key
Sign in to the cabinet, open "API keys", press "New key", pick read or read and write, optionally limit it to certain projects and give it an expiry date. Copy it right away — it is shown once.