Skip to content

Verifying webhooks

When an issue, comment, or project changes, GoalTrack sends a POST request to the URL you registered. Your server must verify the signature to confirm the request really came from GoalTrack before acting on it — without that check, anyone can send fake events to the same URL.

A workspace owner or admin opens Settings › Webhooks and picks the receiving URL (an https URL reachable from the public internet) and the events to receive, and sets a signing secret (at least 8 characters). Keep the same secret on your receiving server. A workspace can register up to 20 webhooks.

  • POST, content-type: application/json
  • Body: { "event", "event_id", "workspace_id", "occurred_at", "payload" }
Event When
work_item.created An issue was created
work_item.updated Fields such as title, priority, or due date changed (payload.changed lists the field names)
work_item.assigned The assignee changed
work_item.status_changed The status changed
work_item.deleted An issue was deleted (id and minimal identifiers only)
comment.created A comment was added to an issue
comment.deleted A comment was deleted
project.created A project was created
project.status_changed A project’s status changed
project.deleted A project was deleted
import.completed An import was confirmed (one event with counts, not one per row)

payload carries identifiers and a few fields such as titles and names only. If you need bodies, descriptions, or comment text, read them back with an API key. New event types can be added, so accept unknown event values with a 2xx and ignore them. No events are sent for draft issues.

Header Meaning
x-goaltrack-event Event name. It is not signed, so branch on the body’s event.
x-goaltrack-timestamp Send time (epoch seconds).
x-goaltrack-event-id Deduplication key. The same across retries, and the same even if one URL is registered twice.
x-goaltrack-delivery-id For tracing a delivery. Different per registered URL, the same across retries.
x-goaltrack-signature-v2 The signature, as t=<time>,v2=<hex>.

The signature is the hex of HMAC-SHA256(secret, "<event_id>.<t>.<rawBody>").

  1. Use the body exactly as received. Parsing the JSON and serializing it again changes the bytes and the signature will not match.
  2. Reject the request if the signature’s t is more than 300 seconds away from now (this stops a captured request from being replayed later).
  3. Compare your computed value with v2 using a constant-time comparison.
  4. Once verified, check whether you already processed this event_id. If you did, return a 2xx and skip it.

Node example (copy it as is):

verify-goaltrack-webhook.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody = the request body exactly as received (parsing and re-serializing JSON changes the bytes)
// headers = object with lowercase keys (Node req.headers). With the Fetch API (Next.js, Workers), pass Object.fromEntries(request.headers)
function verifyGoalTrackWebhook(rawBody, headers, secret, toleranceSec = 300) {
const eventId = String(headers['x-goaltrack-event-id'] ?? '');
const parts = Object.fromEntries(
String(headers['x-goaltrack-signature-v2'] ?? '').split(',').map((p) => p.split('=', 2)),
);
const t = Number(parts.t);
if (!/^[A-Za-z0-9_-]{1,128}$/.test(eventId)) return false;
if (!/^\d+$/.test(parts.t ?? '') || !/^[0-9a-f]{64}$/.test(parts.v2 ?? '')) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false; // replay or clock skew
const expected = createHmac('sha256', secret)
.update(`${eventId}.${t}.${rawBody}`, 'utf8')
.digest();
if (!timingSafeEqual(Buffer.from(parts.v2, 'hex'), expected)) return false;
// Verified: eventId is signed. If you already processed this eventId, return 2xx and skip it.
// Branch on JSON.parse(rawBody).event (the x-goaltrack-event header is not signed).
return true;
}
  • Only 2xx counts as success. Redirects (3xx) are not followed.
  • 4xx, 5xx, connection failures, and no response within 10 seconds count as failures and are retried after 5 · 30 · 300 · 1800 · 7200 seconds (up to 6 attempts including the first). A retry carries the same event_id with a new timestamp and signature.
  • If you answer 429 with Retry-After, the next attempt waits until then (up to 7200 seconds).
  • Order is not guaranteed and the same event can arrive twice — deduplicate on event_id.
  • When 10 deliveries in a row fail after using up their retries, the webhook is switched off automatically. Events that pile up while it is off are not sent when you switch it back on.