Public API & webhooks
The Public API lets your own tools read and write heedback data, and webhooks push signed events to your systems the moment something changes. Both are available on the Team plan. If your workspace is on the Team trial, API keys and webhooks stop working the day the trial ends: API requests are refused and events are no longer sent. Your keys and endpoints are kept. See what changes when the trial ends.
Create and manage credentials under Settings → API and Settings → Webhooks in the dashboard.
Authentication
Every request to /api/v1/* is authenticated with an API key sent as a
bearer token:
curl -H "Authorization: Bearer $HEEDBACK_API_KEY" \
https://YOUR_WORKSPACE.heedback.app/api/v1/boards-
Keys are shown once at creation — copy the key then; it can't be shown again. Only a short prefix is stored for display.
-
Each key has a scope, which decides which endpoints it can reach:
Scope Shown as Can do read"Read only" GET endpoints only write_only"Write only" POST and PATCH only — cannot read at all write"Read & write" Everything Pick Write only for an integration that just pushes data in — a script filing posts from your support inbox, for example. It can't list boards, posts, votes or comments, so a leaked write-only key can't be used to walk your workspace's data. Both directions are enforced: a write-only key on a GET returns
403 insufficient_scope, exactly as a read-only key does on a POST.One caveat:
POSTandPATCHstill return the record they just wrote, so a write-only key that already knows a post's id can see that post's fields by changing its status. It has no way to discover ids it wasn't given. -
Set an expiry at creation. Pick a preset (1 day, 1 week, 1 month, 3 months, 1 year, or Never) or choose Custom… and pick an exact date. The date shown beside the dropdown is the day the key stops working — keys expire at the end of that day, UTC. Never is the default.
-
Rotate a key (reissue a new secret), disable/enable it (reversible), or delete it. Any change — rotate, disable, delete, or expiry — stops the key working within ~30 seconds.
-
Requests are rate-limited to 120 requests/minute per key — over the limit returns
429with aRetry-Afterheader. The limit is counted per key across all of our servers, in one-minute windows that start on the minute.
Using heedback from an AI assistant
The same API keys work with heedback's MCP server, which lets
Claude Code, Cursor or Claude Desktop read your feedback and, with a
write key, triage it: change statuses, merge duplicates and draft changelog
entries. MCP requests and API requests share the one 120-per-minute budget of
the key they use.
Endpoints (v1)
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/boards | read | List boards |
| GET | /api/v1/boards/{id} | read | Get one board |
| GET | /api/v1/posts?boardId=… | read | List a board's posts (cursor-paginated, limit ≤ 100) |
| POST | /api/v1/posts | write | Create a post (boardId, title, optional body, statusId) |
| GET | /api/v1/posts/{id} | read | Get one post |
| PATCH | /api/v1/posts/{id} | write | Change a post's status (statusId) |
| GET | /api/v1/posts/{id}/votes | read | List a post's votes |
| POST | /api/v1/posts/{id}/votes | write | Cast a vote (voterId) |
| GET | /api/v1/posts/{id}/comments | read | List a post's comments |
| POST | /api/v1/posts/{id}/comments | write | Add a comment (body) |
| GET | /api/v1/changelog | read | List published changelog entries |
| POST | /api/v1/changelog | write | Create a draft entry (never auto-published) |
Post, vote and comment responses include recordedByTeam. When true, a
workspace maker recorded the action for a customer; the customer did not
personally submit that post, cast that vote or write that comment through
Heedback. The v1 API returns voterId: "team-recorded:<vote id>" for a
recorded vote — unique per vote, so counting distinct voterIds stays correct
— and prefixes a recorded post's or comment's authorName with
Team (recorded for …), or Team (recorded for customer) when your team
entered no name. Test recordedByTeam rather than matching on the voterId or
authorName text. The vote.cast webhook carries the same voterId.
The customer's email is never returned, and neither is the teammate who recorded the action. The API cannot create recorded actions; use the dashboard.
Internal notes are not part of the
API. The comments endpoints never return a note and cannot create one, and the
comment.created webhook is not sent for them.
Pagination: list responses return a nextCursor — pass it back as
?cursor=… for the next page.
Request bodies
Send JSON. A body over 64 KB is refused with 413; one that isn't valid JSON,
or isn't a JSON object, is refused with 400.
| Field | Limit | If you exceed it |
|---|---|---|
title | 200 characters | Shortened to fit |
body (post or comment) | 5,000 characters | Shortened to fit |
body (changelog entry) | 10,000 characters | Shortened to fit |
authorName | 80 characters | Shortened to fit |
authorEmail | A valid address, 254 characters | 400 |
voterId | 200 characters | 400 |
boardId, statusId, productId | 64 characters | 400 |
label (changelog entry) | 64 characters | 400 |
Text is shortened rather than refused so a long customer message still becomes a
post. Identifiers are refused instead, because a shortened id would point at
something else. Every field must be a string; a number or object in its place
returns 400 with a message naming the field.
A comment's authorName can't be heedback (in any capitalisation): that name
marks heedback's own status comments, which can't be deleted. It's refused
with 400.
An optional field you have no value for can be left out, sent as null, or sent
as an empty string — all three mean "not given".
Error format
Errors return { "error": { "code", "message" } } with these codes:
| Status | code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, malformed, unknown, or revoked key |
| 403 | insufficient_scope | A read key on a write route, or write_only on a read route |
| 403 | not_entitled | The workspace's plan no longer includes API access |
| 404 | not_found | The resource doesn't exist in your workspace |
| 429 | rate_limited | Over 120 req/min — see Retry-After |
| 400 | bad_request | A required field is missing or invalid |
| 413 | payload_too_large | The request body is over 64 KB |
| 503 | service_unavailable | Verification is temporarily unavailable |
A key for one workspace can never read or write another workspace's data —
foreign ids return 404.
Webhooks
Add an endpoint under Settings → Webhooks, choose which events to receive, and copy the signing secret (shown once). heedback POSTs a JSON envelope to your URL for each subscribed event:
{
"product": "heedback",
"event": "post.created",
"occurredAt": 1730000000000,
"payload": { "postId": "…", "boardId": "…", "title": "…" }
}Event catalog
| Event | Fires when |
|---|---|
post.created | A post is created (dashboard, portal, widget, or API) |
post.status_changed | A post's status changes |
vote.cast | A vote is cast |
comment.created | A comment is added |
changelog.published | A changelog entry goes live |
Authenticating deliveries: HMAC or shared secret
When you create an endpoint, choose how deliveries authenticate themselves:
- HMAC signature (recommended, default) — each delivery is signed and sent
with an
X-Webhook-Signatureheader. Nothing secret is transmitted, and the signature also proves the body wasn't altered in transit. Verify it as shown below. - Shared secret header — each delivery instead sends
Authorization: Bearer <secret>with your endpoint secret. Simpler to check (compare the header to your stored secret), but the secret travels on every request and it doesn't prove body integrity. Use it only if HMAC is impractical for your receiver.
Headers
Common to every delivery:
X-Webhook-Timestamp: <unix_ms>
X-Webhook-Id: <deliveryId> # idempotency key — delivery is at-least-once
X-Webhook-Event: post.createdHMAC mode adds X-Webhook-Signature: sha256=<hex>; shared-secret mode adds
Authorization: Bearer <secret> instead.
Verifying a delivery
A webhook is an outbound request from Heedback to your URL — there's no login in front of your receiver, so anyone who learns the URL could POST fake events. The signature is how you authenticate that a delivery genuinely came from Heedback (only you and Heedback know the endpoint's secret) and wasn't tampered with in transit. Always verify it — even if your endpoint also sits behind your own auth.
Recompute the HMAC over ${timestamp}.${rawBody} with your secret and compare it
to X-Webhook-Signature. Reject if the timestamp is more than 5 minutes old
(replay protection), and treat X-Webhook-Id as an idempotency key.
import crypto from "node:crypto";
function verify(req, secret) {
const ts = req.headers["x-webhook-timestamp"];
const sig = req.headers["x-webhook-signature"]; // "sha256=<hex>"
const expected =
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(`${ts}.${req.rawBody}`)
.digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
const fresh = Date.now() - Number(ts) < 5 * 60_000;
return ok && fresh;
}Language-agnostic pseudocode:
expected = "sha256=" + HMAC_SHA256(secret, timestamp + "." + rawBody)
valid = constant_time_equals(expected, X-Webhook-Signature)
AND (now - timestamp) < 5 minutesRetries & delivery log
A failing endpoint is retried with exponential backoff (up to ~6 attempts). Every attempt is recorded in the delivery log on the Webhooks settings page. An endpoint that fails 10 consecutive events is auto-disabled and the workspace owner is emailed; re-enable it from settings once it's healthy again.