Skip to content

Webhooks

Simple Feature Requests can push real-time event notifications to an HTTPS endpoint you control. When a subscribed event occurs in your team or project, the API sends a signed HTTP POST request to your endpoint within seconds.


Overview

  1. Register an endpoint — provide an HTTPS URL and choose which event types to subscribe to.
  2. Receive signed payloads — the API POSTs a JSON body and a signature header to your URL.
  3. Verify the signature — use your signing secret to confirm the request is authentic.
  4. Respond quickly — return any 2xx status within 10 seconds to acknowledge delivery.

Registering an endpoint

Endpoints are managed via the Webhooks section of team settings in the admin webapp, or programmatically through the GraphQL API / SDK.

Via the GraphQL API

mutation CreateWebhookEndpoint($input: CreateWebhookEndpointInput!) {
  createWebhookEndpoint(input: $input) {
    id
    url
    status
    eventTypes
    signingSecret   # shown once — store it securely now
  }
}
{
  "input": {
    "teamId": "<your-team-id>",
    "url": "https://example.com/webhooks/sfr",
    "description": "My Slack integration",
    "eventTypes": ["request.created", "request.status_changed"]
  }
}

The signingSecret is returned only at creation time (and when you rotate it). Store it immediately — it cannot be retrieved again.

Via the SDK

const endpoint = await client.webhooks.create({
  teamId: 'your-team-id',
  url: 'https://example.com/webhooks/sfr',
  description: 'My Slack integration',
  eventTypes: ['request.created', 'request.status_changed'],
});

console.log(endpoint.signingSecret); // store this securely

Enabling an endpoint

New endpoints are created with status: "disabled". Enable them before events are delivered:

mutation {
  updateWebhookEndpoint(
    id: "<endpoint-id>"
    input: { status: "enabled" }
  ) {
    id
    status
  }
}

Payload

Every webhook delivery is an HTTP POST with Content-Type: application/json and a JSON body:

{
  "schemaVersion": 1,
  "event": "request.created",
  "deliveryId": "d3e4f5a6-...",
  "occurredAt": "2026-06-12T10:00:00.000Z",
  "data": {
    "requestId": "abc12345-...",
    "title": "Dark mode support",
    "boardId": "...",
    "teamId": "...",
    "projectId": "..."
  }
}
Field Type Description
schemaVersion integer Payload schema version. Currently 1.
event string The event type (see Event catalog).
deliveryId string (UUID) Unique ID for this delivery attempt. Use it to deduplicate.
occurredAt ISO 8601 string When the event occurred, in UTC.
data object Event-specific payload. Fields vary by event type.

The data object contains entity IDs and relevant fields for the event type. The exact shape varies per event; treat unknown fields as forward-compatible additions.


Request headers

Header Description
Content-Type application/json
X-SFR-Event The event type (e.g. request.created)
X-SFR-Delivery The delivery UUID (same as deliveryId in the body)
X-SFR-Signature HMAC-SHA256 signature — see below
X-SFR-Timestamp ISO 8601 timestamp used in the signature
User-Agent SFR-Webhook/1.0

Verifying signatures

Every request is signed with HMAC-SHA256 using your endpoint's signingSecret. Always verify the signature before processing the payload.

Signature format

X-SFR-Signature: t=<timestamp>,v1=<hmac_hex>
  • t — ISO 8601 timestamp (same as X-SFR-Timestamp).
  • v1 — HMAC-SHA256 hex digest over <timestamp>.<raw_json_body>.

Verification examples

const crypto = require('crypto');

function verifyWebhook(rawBody, signatureHeader, secret) {
  // Parse "t=...,v1=..."
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('='))
  );
  const timestamp = parts.t;
  const receivedHmac = parts.v1;
  if (!timestamp || !receivedHmac) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(receivedHmac, 'hex'),
  );
}
import hashlib
import hmac

def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    parts = dict(p.split('=', 1) for p in signature_header.split(','))
    timestamp = parts.get('t')
    received = parts.get('v1')
    if not timestamp or not received:
        return False

    signed = f'{timestamp}.{raw_body.decode()}'
    expected = hmac.new(
        secret.encode(), signed.encode(), hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, received)
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strings"
)

func verifyWebhook(rawBody []byte, sigHeader, secret string) bool {
    parts := make(map[string]string)
    for _, p := range strings.Split(sigHeader, ",") {
        kv := strings.SplitN(p, "=", 2)
        if len(kv) == 2 {
            parts[kv[0]] = kv[1]
        }
    }
    ts, v1 := parts["t"], parts["v1"]
    if ts == "" || v1 == "" {
        return false
    }
    signed := ts + "." + string(rawBody)
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signed))
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(v1))
}

Use the raw body

Parse the JSON after verifying the signature. Compute the HMAC over the exact raw bytes received — do not re-serialize the parsed JSON.

Replay protection

Consider rejecting requests where t is more than 5 minutes in the past to protect against replay attacks.


Retries and timeouts

Behaviour Details
Response timeout Your endpoint must respond within 10 seconds. Timeouts are treated as failures.
Success Any 2xx response code is treated as successful delivery. The response body is ignored.
Retryable Network errors, 408, 429, and 5xx responses are retried with exponential backoff.
Non-retryable 4xx responses (except 408 and 429) are not retried and the delivery is marked failed.
Max attempts After 8 attempts the delivery is moved to dead (DLQ) and no further retries occur.
Retry schedule 5 s → 10 s → 20 s → 40 s → 80 s → 160 s → 5 min → dead

Failed and dead deliveries can be retried manually from the admin webapp or via the redeliverWebhookDelivery mutation.


Event catalog

The following event types are available for subscription. Only customer-facing events are included — internal auth, billing, and admin slug-rename events are not delivered to webhooks.

Requests

Event When it fires
request.created A new feature request is submitted
request.updated A request's title or description is edited
request.status_changed A request moves to a new status
request.deleted A request is deleted
request.tagged A tag is added to a request
request.untagged A tag is removed from a request

Comments

Event When it fires
comment.created A comment is posted
comment.updated A comment body is edited
comment.deleted A comment is deleted
comment.visibility_changed A comment is hidden or unhidden by a moderator

Votes

Event When it fires
vote.added A user upvotes a request
vote.removed A user removes their vote

Attachments

Event When it fires
attachment.created A file attachment is added to a request
attachment.deleted An attachment is deleted

Projects

Event When it fires
project.created A new project is created
project.updated A project's settings are changed
project.deleted A project is deleted

Statuses

Event When it fires
status.created A new status is added to a board
status.updated A status is renamed or reordered
status.deleted A status is removed

Tags

Event When it fires
tag.created A new tag is created in a project
tag.updated A tag is renamed or recoloured
tag.deleted A tag is deleted

Security recommendations

  • Always verify the signature before processing any payload.
  • Use HTTPS — HTTP endpoints are rejected in production.
  • Respond promptly — return 2xx as soon as you receive and verify the payload; defer heavy processing to a background queue.
  • Deduplicate on deliveryId — the same event may be delivered more than once if a retry occurs after your server acknowledged it but before the API recorded success.
  • Keep the signing secret confidential — treat it like an API key. Rotate it from the webapp or API if it is ever exposed.

Managing endpoints

Rotate a signing secret

mutation {
  rotateWebhookEndpointSecret(id: "<endpoint-id>") {
    id
    signingSecret  # new secret — store immediately
  }
}

The old secret stops working immediately after rotation.

View delivery history

query {
  webhookDeliveries(endpointId: "<endpoint-id>", limit: 20) {
    items {
      id
      eventType
      status
      attemptCount
      lastResponseStatus
      lastError
      deliveredAt
      createdAt
    }
    totalCount
    hasNextPage
  }
}

Manually retry a delivery

mutation {
  redeliverWebhookDelivery(id: "<delivery-id>")
}

Send a test event

mutation {
  sendTestWebhook(endpointId: "<endpoint-id>") {
    success
    responseStatus
    error
  }
}

The test payload uses event: "test" and does not represent a real domain event.