Most feedback tooling asks you to trust a sync: something polls a remote system on a schedule, and you find out later that it stopped. TopDo’s GreatFeedback integration is built the other way round. TopDo never calls GreatFeedback. GreatFeedback posts a signed event to a receiver URL, TopDo verifies the signature, checks that the event belongs to this integration, and records it exactly once as a node under a parent you chose in advance. This article walks that path in the order the code walks it.

GreatFeedback is not a connection provider
TopDo has exactly three connection providers — slack, github and jira — and they behave like connections: you save credentials, you call test_connection, and you call sync_connection to fetch. GreatFeedback is a separate subsystem with none of that surface. There is no test_connection for it, no sync_connection, no polling job, and no outbound HTTP call to GreatFeedback anywhere in the codebase. Its configuration lives in its own table, feedback_integrations, and its only entry point is an inbound webhook.
The distinction is worth holding onto, because the two designs fail differently. A pull integration fails silently: the schedule runs, the fetch returns nothing useful, and the queue looks calm. A push integration fails loudly at the sender, which retries, and every retry lands on the same verification chain and the same receipt table. You get an error code rather than an absence.
It also sets your expectations for the real connections correctly. Live fetch exists for Slack only; github and jira with mode=live raise 422 live_fetch_unavailable and tell you to ask for mode=replay explicitly, which reads static fixtures rather than the live service. If you need GitHub issues streaming into the graph today, that is not what is shipped. Feedback is, and it is shipped as a push.
Choose the destination before you generate the secret
Configuration is five fields on feedback_integrations: parent_id, external_workspace_id, site_id, secret_cipher and active. The parent is the node that incoming feedback is filed under, and it may not be a template — the save is rejected with “Feedback cannot arrive under a workflow template”, because template subtrees are copied by run_process, not populated by visitors. Pick a real intake branch for the product area under review.
The signing secret must be 20 to 300 characters. It is encrypted at rest with AES-256-GCM under a key derived from CONNECTIONS_KEY, stored in the "v2.<iv>.<tag>.<ciphertext>" form, and never returned. listFeedback does not select secret_cipher at all, and the connection side of the same codebase masks a stored secret as the literal string "set" rather than echoing it. If you lose your copy you rotate it; you do not read it back out of TopDo.
Every management route — GET and POST /integrations/greatfeedback, DELETE /integrations/greatfeedback/:id — requires the admin role, and the feature is gated by the workspace plan. DELETE is a soft disable: it sets active=false and leaves the row, its receipts and everything already filed in place. The receiver then stops accepting events for that id rather than losing the record of what it accepted before.
The receiver is unauthenticated and verified seven ways
POST /integrations/greatfeedback/:integration_id carries no session and no API key. It cannot: the caller is another product’s webhook dispatcher. Authentication is the signature, and the route earns the right to be open by refusing everything that does not survive the chain below, in this order.
- The body is read through a bounded reader and cut off past 256 KiB with 413
body_too_large, so an oversized payload never reaches JSON parsing. - The integration id is resolved to a workspace, and only an
activerow is loaded. - The signature header
x-gf-signatureis parsed ast=<unix-seconds>,v1=<hex>, and the HMAC-SHA256 is computed over"{ts}."followed by the raw bytes — not the re-serialized JSON. - The timestamp must be within 300 seconds of now, in either direction, or the request fails with 401
invalid_signature. - The digests are compared with
timingSafeEqualafter a length check, so a wrong signature leaks no timing signal. - The headers
x-gf-eventandx-gf-deliverymust equal theeventandidinside the envelope, so the routing metadata and the payload cannot disagree. data.workspace_idmust equalexternal_workspace_id, and whensite_idis configured,data.site_idordata.project_idmust match it, else 403source_mismatch.
ts = 1758067200 # unix seconds
sig = hmac_sha256(secret, ts + "." + rawBody).hex()
POST /integrations/greatfeedback/{integration_id}
x-gf-signature: t=1758067200,v1=<sig>
x-gf-event: feedback.created
x-gf-delivery: evt_8f2c1a
content-type: application/jsonSigning the raw bytes is the detail people get wrong when they write their own sender. If your dispatcher re-encodes the JSON between signing and sending — reordering keys, changing whitespace, dropping a Unicode escape — the digest will not match, and you will spend an afternoon blaming the clock. Sign the exact buffer you put on the wire.
Only two events become work
The receiver accepts feedback.created and review.completed. Anything else is rejected as an unsupported event, so subscribing your publisher to a wider set does not quietly fill the graph with events TopDo has no meaning for. Each accepted event must carry a source identifier — data.annotation_id for a created note, data.review_id for a completed review — matching [A-Za-z0-9_-]{1,160}.
What it becomes is fully determined. A node is created under parent_id with state: "open" and labels: ["feedback"]. The title is the first line of the comment, truncated to 140 characters. An empty comment and summary fall back to “Review completed” first, so that is the title you see; “New feedback” is reached only when the text starts with a newline and the first line is therefore empty. The body is the comment, capped at 50,000 characters, followed by a provenance footer naming the source, the event and the source id, plus the page URL when the payload carried one.
One more step makes it actionable rather than merely present. If the new node has no type, TopDo sets type='task' and allocates a queue_rank. That is what puts it in the queue instead of leaving it as an untyped page somebody has to go looking for. The seeded task type has is_action: true; the seeded doc type does not, and untyped nodes get no queue position at all.
A duplicate and a conflict are different answers
Idempotency lives in feedback_receipts, keyed on (integration_id, event_id) and holding the SHA-256 of the raw body along with the node it produced. When an event id arrives that has been seen before, TopDo compares body hashes. Identical body: it returns {status: "duplicate"} with the original node_id and writes nothing. Different body: it raises 409 event_conflict, “This event ID was already used with different content”.
Collapsing those two into one answer is the tempting simplification, and it is the wrong one. A duplicate is a delivery you already accepted, arriving again because the sender did not see your 200. Returning success is correct, and returning the same node id lets the sender reconcile. A conflict is a different fact wearing an identifier you already spent. That is a bug in the sender, a replay by a third party, or an id space that is not unique — and answering “fine, already have it” would mean silently dropping a customer’s words.
A duplicate is the same fact arriving twice. A conflict is a different fact wearing an identifier you already spent. Only one of them is safe to answer with success.
Rotation has its own answer. The secret is read once when the request is verified and checked again inside the write transaction; if it changed in between, the write fails with 401 integration_changed, “Integration credentials changed; retry with the current secret”. An in-flight event signed with the retired secret is rejected rather than half-applied, and the sender retries with the new one.
A later event does not overwrite claimed work
Feedback about one annotation rarely arrives once. The ingestion path maps a source record to a node through source_map, keyed on the integration, the event name and the source id, with a content hash beside it. An unchanged body returns unchanged. A changed body updates the node in place — but only while the node still carries suggested: true in its metadata.
Claiming the work removes that flag. From then on, changed source text is appended as a comment that says the source changed after the item was claimed, and the action comes back as noted rather than updated. Nobody’s edits are overwritten by a publisher, and nobody loses the new text either. The distinction between “nothing has touched this yet” and “someone owns this now” is the whole of it.
This matters most when an agent is working the queue. TopDo does not run agents; it gives them a place to read work, claim it and leave an update through REST and MCP with scoped keys. An agent holding a claim on a feedback node and a customer adding a sentence to the same annotation is an ordinary race, and the resolution is already written down: the agent keeps its body, the customer’s sentence lands as a comment.
Closing the node is not verifying the fix
The node the webhook created starts open and is closed by a person or an agent. Nothing about that close checks the customer’s original complaint. State is state. If your process needs the close to mean “verified”, you have to say so in the type registry, because the seeded task type ships with rules: [] and gates nothing.
The instrument is a gated_close rule naming an edge type. Add {rule: "gated_close", via: "verifies"} to the node type used for feedback work, link the verification node with a verifies edge, and closing while that verification is still open raises 422 close_gated and returns the blockers by number and title. verifies is one of the eleven seeded link types and is parseable, so [[verifies::nd_…]] in the body creates the edge.
There is a second gate you get without asking. If the requirement behind the work changed and the node carries entries in pending_changes, closing it raises 409 changes_pending, “Review changed requirements before completing this work”. The feedback that started the thread and the specification that governs the fix stay tied to the same close.
Check these seven things before you point a publisher at it
Everything below is checkable against a live integration in an afternoon, and each check corresponds to a specific failure people actually hit. Run them in order; the earlier ones cause the later ones.
- Confirm the destination parent is a live node and not a template, and that it is where a person would look for intake rather than an archive branch.
- Send one event with a deliberately stale timestamp and confirm 401
invalid_signature; that proves the 300-second window is really being enforced against your sender’s clock. - Send the same event id twice with the identical body and confirm
{status: "duplicate"}and the samenode_id, then send it with one character changed and confirm 409event_conflict. - Set
site_idon the integration and send an event without one; a 403source_mismatchis the correct answer, and a workspace-wide subscription is the deliberate alternative, not a workaround. - Check that a newly created feedback node has
type='task'and aqueue_rank, so it appears in the queue rather than sitting as an untyped page. - Claim a feedback node, then send the same
annotation_idunder a newevent_idwith changed text, and confirm the new text arrives as a comment with actionnotedinstead of overwriting the body. Reusing the originalevent_idis 409event_conflictand never reaches this path. - Rotate the secret and replay an in-flight event; 401
integration_changedis what you want to see, followed by success on the retry with the new secret.
The integration is in private beta and it does one thing: it turns a verified push into a node with a place, a type and a queue position. It does not triage, it does not judge severity, and it does not decide whether the customer’s problem is solved. Those remain yours. What it removes is the part nobody should be doing by hand — moving a customer’s words into the system where the work already lives, exactly once, with the source attached.