heedbackdocs

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:

    ScopeShown asCan 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: POST and PATCH still 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 429 with a Retry-After header. 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)

MethodPathScopeDescription
GET/api/v1/boardsreadList boards
GET/api/v1/boards/{id}readGet one board
GET/api/v1/posts?boardId=…readList a board's posts (cursor-paginated, limit ≤ 100)
POST/api/v1/postswriteCreate a post (boardId, title, optional body, statusId)
GET/api/v1/posts/{id}readGet one post
PATCH/api/v1/posts/{id}writeChange a post's status (statusId)
GET/api/v1/posts/{id}/votesreadList a post's votes
POST/api/v1/posts/{id}/voteswriteCast a vote (voterId)
GET/api/v1/posts/{id}/commentsreadList a post's comments
POST/api/v1/posts/{id}/commentswriteAdd a comment (body)
GET/api/v1/changelogreadList published changelog entries
POST/api/v1/changelogwriteCreate 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.

FieldLimitIf you exceed it
title200 charactersShortened to fit
body (post or comment)5,000 charactersShortened to fit
body (changelog entry)10,000 charactersShortened to fit
authorName80 charactersShortened to fit
authorEmailA valid address, 254 characters400
voterId200 characters400
boardId, statusId, productId64 characters400
label (changelog entry)64 characters400

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:

StatuscodeMeaning
401unauthorizedMissing, malformed, unknown, or revoked key
403insufficient_scopeA read key on a write route, or write_only on a read route
403not_entitledThe workspace's plan no longer includes API access
404not_foundThe resource doesn't exist in your workspace
429rate_limitedOver 120 req/min — see Retry-After
400bad_requestA required field is missing or invalid
413payload_too_largeThe request body is over 64 KB
503service_unavailableVerification 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

EventFires when
post.createdA post is created (dashboard, portal, widget, or API)
post.status_changedA post's status changes
vote.castA vote is cast
comment.createdA comment is added
changelog.publishedA 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-Signature header. 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.created

HMAC 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 minutes

Retries & 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.