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.
Registering
Section titled “Registering”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.
Request
Section titled “Request”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.
Headers
Section titled “Headers”| 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>. |
Verifying the signature
Section titled “Verifying the signature”The signature is the hex of HMAC-SHA256(secret, "<event_id>.<t>.<rawBody>").
- Use the body exactly as received. Parsing the JSON and serializing it again changes the bytes and the signature will not match.
- Reject the request if the signature’s
tis more than 300 seconds away from now (this stops a captured request from being replayed later). - Compare your computed value with
v2using a constant-time comparison. - 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):
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;}Responses and retries
Section titled “Responses and retries”- 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_idwith a new timestamp and signature. - If you answer
429withRetry-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.