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¶
- Register an endpoint — provide an HTTPS URL and choose which event types to subscribe to.
- Receive signed payloads — the API POSTs a JSON body and a signature header to your URL.
- Verify the signature — use your signing secret to confirm the request is authentic.
- Respond quickly — return any
2xxstatus 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¶
t— ISO 8601 timestamp (same asX-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
2xxas 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¶
Send a test event¶
The test payload uses event: "test" and does not represent a real domain event.