Webhook events

Full catalog of webhook events emitted by Forkpoint, with payload shapes and triggering conditions.

Webhook events

Forkpoint delivers event notifications via user-registered webhooks. When something happens in your account (a review is approved, tasks are exported, an email share is accepted…), Forkpoint POSTs a JSON envelope to every active webhook that subscribes to the event.

Every delivery is HMAC-SHA256-signed with the webhook’s secret. Receivers must verify the signature before trusting the payload. Signing details live in /docs/webhooks/signing (coming soon).

Subscribing

  1. Go to /profile/webhooks.
  2. Click Create webhook.
  3. Paste your HTTPS endpoint URL (HTTP is rejected at the domain layer).
  4. Select the events you want to receive.
  5. Save. Forkpoint returns the signing secret once — copy it into your receiver’s config.

You can also use the REST API with the webhooks:manage scope:

curl -X POST https://forkpoint.app/api/webhooks \
  -H 'Authorization: Bearer <YOUR_API_KEY>' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://your-app.example.com/hooks/forkpoint",
    "events": [
      "estimation.approved",
      "estimation.exported",
      "share.accepted"
    ]
  }'

The response includes data.webhook (the subscription metadata) and data.secret (the plaintext signing secret, returned once). Store the secret alongside your service’s other credentials.

Retries

Failed deliveries (non-2xx response, network error, or timeout after 10s) are retried automatically with exponential backoff at 1 min, 5 min, 30 min. After three total attempts the delivery is marked failed and no more retries are scheduled. Every attempt is recorded in the webhook’s delivery log, which you can inspect via the dashboard or GET /api/webhooks/:id/deliveries.

Test delivery

From the dashboard, click Send test on any webhook to receive a webhook.test event right away. The test uses the same signing and retry path as a real dispatch, so it is a faithful check of your receiver.

Payload envelope

Every delivery is a JSON object with the same top-level shape. The data field holds event-specific fields documented per-event below.

{
  "id": "wd_01HABCD...",
  "event": "estimation.approved",
  "createdAt": "2026-04-19T12:34:56.789Z",
  "data": {
    "estimationId": "est_01HXYZ...",
    "reviewerId": "usr_01HXYZ...",
    "reviewerName": "Jane Doe",
    "estimationTitle": "Acme Rebrand",
    "estimationUrl": "https://forkpoint.app/estimations/est_01HXYZ..."
  }
}

TODO verify — envelope fields. The per-event payloads below reflect the exact object that the dispatcher passes to your endpoint today; the id, event, and createdAt wrapper shown here is the intended contract going forward but the current dispatcher in apps/api/src/webhooks/application/webhook.use-cases.ts POSTs the data object at the top level with event metadata delivered via headers (X-Forkpoint-Event, X-Forkpoint-Delivery, X-Forkpoint-Signature). If you are integrating today, read the event from the X-Forkpoint-Event header and the delivery id from X-Forkpoint-Delivery.

Request headers you can rely on:

HeaderMeaning
Content-TypeAlways application/json.
User-AgentForkpoint-Webhooks/1.0.
X-Forkpoint-EventThe event name (e.g. estimation.approved).
X-Forkpoint-DeliveryUnique id for this delivery attempt — use for idempotency.
X-Forkpoint-Signaturesha256=<hex> HMAC-SHA256 of the raw request body, computed with your webhook’s secret.

Event catalog

EventWhen it firesPayload fields
estimation.submittedAn estimation owner requested a review. The event targets the reviewer assignment, not the estimation state.estimationId, reviewerId, ownerName, estimationTitle, reviewUrl
estimation.approvedA reviewer approved the estimation.estimationId, reviewerId, reviewerName, estimationTitle, estimationUrl
estimation.changes_requestedA reviewer requested changes on the estimation.estimationId, reviewerId, reviewerName, estimationTitle, estimationUrl
estimation.tasks_generatedTasks were (re)generated from the decision tree. Fires after persistence, once per generation run.estimationId, taskCount
estimation.exportedTasks were exported to a downstream tracker (Jira or GitHub). Fires once per export run; taskCount is the number of issues created in this run.estimationId, taskCount, projectKey, estimationTitle, estimationUrl
share.invitedAn estimation was shared with an email recipient (email share created).shareId, estimationId, sharerName, recipientEmailHash, estimationTitle, estimationUrl
share.acceptedA previously invited user logged in and auto-accepted their pending email share.shareId, estimationId, accepterEmail
webhook.testThe user pressed “Send test” on a webhook from the dashboard. Not subscribable — always delivered to the chosen webhook regardless of its event list.event, userId, timestamp

estimation.submitted

An estimation owner requested a review. The event targets the reviewer assignment, not the estimation state.

Source: apps/api/src/review/application/review.use-cases.ts

{
  "id": "wd_01HABCD...",
  "event": "estimation.submitted",
  "createdAt": "2026-04-19T12:34:56.789Z",
  "data": {
    "estimationId": "est_01HXYZ...",
    "reviewerId": "usr_01HXYZ...",
    "ownerName": "Jane Doe",
    "estimationTitle": "Acme Rebrand",
    "reviewUrl": "https://forkpoint.app/estimations/est_01HXYZ.../review"
  }
}

estimation.approved

A reviewer approved the estimation.

Source: apps/api/src/review/application/review.use-cases.ts

{
  "id": "wd_01HABCD...",
  "event": "estimation.approved",
  "createdAt": "2026-04-19T12:34:56.789Z",
  "data": {
    "estimationId": "est_01HXYZ...",
    "reviewerId": "usr_01HXYZ...",
    "reviewerName": "Jane Doe",
    "estimationTitle": "Acme Rebrand",
    "estimationUrl": "https://forkpoint.app/estimations/est_01HXYZ..."
  }
}

estimation.changes_requested

A reviewer requested changes on the estimation.

Source: apps/api/src/review/application/review.use-cases.ts

{
  "id": "wd_01HABCD...",
  "event": "estimation.changes_requested",
  "createdAt": "2026-04-19T12:34:56.789Z",
  "data": {
    "estimationId": "est_01HXYZ...",
    "reviewerId": "usr_01HXYZ...",
    "reviewerName": "Jane Doe",
    "estimationTitle": "Acme Rebrand",
    "estimationUrl": "https://forkpoint.app/estimations/est_01HXYZ..."
  }
}

estimation.tasks_generated

Tasks were (re)generated from the decision tree. Fires after persistence, once per generation run.

Source: apps/api/src/task/application/task.use-cases.ts

{
  "id": "wd_01HABCD...",
  "event": "estimation.tasks_generated",
  "createdAt": "2026-04-19T12:34:56.789Z",
  "data": {
    "estimationId": "est_01HXYZ...",
    "taskCount": 12
  }
}

estimation.exported

Tasks were exported to a downstream tracker (Jira or GitHub). Fires once per export run; taskCount is the number of issues created in this run. Dispatched from both apps/api/src/task/application/export.use-case.ts (Jira) and apps/api/src/task/application/export-github.use-case.ts (GitHub).

{
  "id": "wd_01HABCD...",
  "event": "estimation.exported",
  "createdAt": "2026-04-19T12:34:56.789Z",
  "data": {
    "estimationId": "est_01HXYZ...",
    "taskCount": 12,
    "projectKey": "ACME",
    "estimationTitle": "Acme Rebrand",
    "estimationUrl": "https://forkpoint.app/estimations/est_01HXYZ..."
  }
}

For GitHub exports, projectKey holds the GitHub repo slug (e.g. my-org/my-repo) instead of a Jira project key. TODO verify — naming: the field is reused for both trackers; a receiver that needs to branch on tracker should inspect the value shape or add its own side-channel flag.

share.invited

An estimation was shared with an email recipient (email share created).

Source: apps/api/src/share/application/share.use-cases.ts

{
  "id": "wd_01HABCD...",
  "event": "share.invited",
  "createdAt": "2026-04-19T12:34:56.789Z",
  "data": {
    "shareId": "shr_01HXYZ...",
    "estimationId": "est_01HXYZ...",
    "sharerName": "Jane Doe",
    "recipientEmailHash": "5efba3df1c3be499380cf0c59ceda286171b90abc05cfac25b882fc4368b391c",
    "estimationTitle": "Acme Rebrand",
    "estimationUrl": "https://forkpoint.app/estimations/est_01HXYZ..."
  }
}

Privacy note — recipientEmailHash (not recipientEmail). From the invitee’s perspective, the webhook endpoint is a third-party processor they never consented to. To keep outbound payloads GDPR-friendly we ship a SHA-256 hex digest of the normalised recipient address (lowercase, trimmed), not the plaintext. Consumers that already know a candidate email can reproduce the hash locally and correlate events; consumers that don’t, can’t reverse-engineer the address. The raw email is still stored internally so we can deliver the actual invite email — the hash only affects what leaves the platform.

Reproduce the hash in any language, e.g.:

import { createHash } from 'node:crypto';
const hash = createHash('sha256')
  .update('recipient@example.com'.trim().toLowerCase())
  .digest('hex');
// → "5efba3df1c3be499380cf0c59ceda286171b90abc05cfac25b882fc4368b391c"

share.accepted

A previously invited user logged in and auto-accepted their pending email share.

Source: apps/api/src/share/application/share.use-cases.ts

{
  "id": "wd_01HABCD...",
  "event": "share.accepted",
  "createdAt": "2026-04-19T12:34:56.789Z",
  "data": {
    "shareId": "shr_01HXYZ...",
    "estimationId": "est_01HXYZ...",
    "accepterEmail": "usr_01HXYZ..."
  }
}

TODO verify — accepterEmail currently carries the accepting user id, not the email address (the dispatcher comment calls this out: “userId is the best we have at this layer”). The field name will migrate to accepterUserId in a future release; treat the value as opaque today.

webhook.test

The user pressed “Send test” on a webhook from the dashboard. Not subscribable — always delivered to the chosen webhook regardless of its event list.

Source: apps/api/src/webhooks/application/webhook.use-cases.ts

{
  "id": "wd_01HABCD...",
  "event": "webhook.test",
  "createdAt": "2026-04-19T12:34:56.789Z",
  "data": {
    "event": "webhook.test",
    "userId": "usr_01HXYZ...",
    "timestamp": "2026-04-19T12:34:56.789Z"
  }
}