Summary
This RFC proposes a general-purpose webhook system for Tuist, modeled after Stripe’s event and webhook infrastructure. Teams can register HTTPS endpoints and subscribe to event types across any Tuist entity (test cases, builds, cache operations, projects, and more). Every event follows a uniform envelope structure, making it straightforward to build integrations with JIRA, Linear, PagerDuty, or any external system.
The initial release focuses on test case events (the most immediate need: automating JIRA ticket creation when flaky tests are quarantined), but the design is entity-agnostic and extends naturally as new event types are added.
Motivation
Today, Tuist supports Slack notifications for certain events like flaky test detection. However, many teams rely on JIRA or other project management tools as their source of truth for tracking work. Without a programmatic hook into Tuist events, teams must manually bridge the gap, which leads to:
- Missed follow-up. Silenced tests are easy to forget about. Without an automatic ticket, a muted or skipped test can remain in that state indefinitely with no one assigned to investigate.
- Manual toil. Engineers context-switch between the Tuist dashboard and their ticketing system, copying names, failure details, and links by hand.
- Inconsistent tracking. Different team members use different formats, labels, or priorities when filing tickets manually.
- No extensibility. When teams want to react to any Tuist state change (a build regression, a cache miss spike, a new project member), there is no generic mechanism to do so.
A webhook system solves this for test silencing today and for every entity Tuist tracks tomorrow, without requiring Tuist to build and maintain direct integrations with each external tool.
Prior Art
Stripe Webhooks
Stripe’s webhook system is the primary inspiration for this design. Key patterns we adopt:
- Uniform event envelope. Every event has the same top-level shape (
id,type,created,object,previous_attributes) regardless of entity type. Consumers write one parser. - Dot-separated event types.
resource.actionnaming (e.g.,customer.created,invoice.payment_failed). Readable, greppable, and naturally hierarchical for wildcard subscriptions. - Snapshot payloads.
objectcontains the full resource state at event time. Consumers do not need to call back to the API to get context. previous_attributes. For*.updatedevents, this field contains only the keys that changed and their prior values. This makes it trivial to detect what changed without diffing against external state.- Signature verification. HMAC-SHA256 with a timestamp prefix (
t=<timestamp>,v1=<signature>) to prevent replay attacks. - At-least-once delivery. Retries with exponential backoff over multiple days. Consumers are expected to handle duplicates via event IDs.
- Endpoint management. Register endpoints via API or dashboard, subscribe to specific event types, view delivery logs, manually redeliver failed events.
We deviate from Stripe in two places. First, we drop the envelope object: "event" discriminator: receivers know they are inside a webhook and id/type are sufficient. Second, we hoist object and previous_attributes to envelope-level fields rather than nesting them under a data wrapper. The wrapper exists in Stripe to group payload fields against metadata fields, but at our scale that grouping adds nesting without paying for itself.
Tuist Automations Evolution RFC
The Automations Evolution RFC proposes a send_webhook action as one of many composable automation actions. The webhook system described here provides the underlying delivery infrastructure that the automations system’s send_webhook action can target. They are complementary: automations define when to fire; webhooks define how to deliver and what the payload looks like.
Proposal
Event Object
Every Tuist event follows a uniform envelope, regardless of entity type:
{
"id": "evt_1RqK4hJf2a9B7cXy",
"type": "test_case.updated",
"created": 1744206720,
"project": {
"id": "proj_abc123",
"full_name": "tuist/tuist"
},
"object": { ... },
"previous_attributes": { ... },
"request": {
"id": "req_8xKdLm2n"
}
}
| Field | Type | Description |
|---|---|---|
id |
string | Unique event identifier (prefixed evt_) |
type |
string | Dot-separated event type (e.g., test_case.updated) |
created |
integer | Unix timestamp of when the event occurred |
project |
object | The project this event belongs to |
object |
object | Full snapshot of the resource at event time. The resource carries its own object field as a type discriminator (e.g., "test_case"). |
previous_attributes |
object, optional | Only present on *.updated events. Contains the keys that changed and their values before the update. Compare against object to detect what changed. |
request |
object or null | The API request or automation that triggered this event |
Event Type Naming
Event types follow a resource.action convention:
We deliberately keep the action vocabulary small and generic. Webhooks are a public API, and a small vocabulary is easier to evolve. New states (e.g., a future deferred quarantine mode), new fields, or relationship changes (labels added or removed) show up as different values inside object and previous_attributes without requiring a new event type. Consumers that care about a specific transition compare object against previous_attributes rather than subscribing to a specialized event.
The base actions are:
| Action | Meaning |
|---|---|
created |
The resource was created |
updated |
One or more fields on the resource changed. object reflects the new state; previous_attributes contains the keys that changed and their prior values. |
deleted |
The resource was deleted |
Test case events (initial release):
| Event Type | Trigger |
|---|---|
test_case.created |
A new test case is first seen |
test_case.updated |
A field on a test case changed. Includes state transitions (enabled, muted, skipped) in either direction, label changes, and any other resource field updates. |
test_case.deleted |
A test case was removed |
Future entity events (illustrative, not part of initial release):
| Event Type | Trigger |
|---|---|
build.created |
A build was uploaded |
build.updated |
A build’s state or metrics changed (e.g., state transitioning to succeeded/failed) |
cache.updated |
Cache metrics for a project changed |
project.updated |
A project’s settings or membership changed (member additions/removals appear as a members diff in previous_attributes) |
bundle.created |
A new bundle was uploaded |
test_run.created |
A test run was uploaded |
test_run.updated |
A test run’s state or metrics changed |
Adding a new event type requires only defining the object schema for that resource. The envelope, delivery, signing, and retry infrastructure are all reused.
Webhook Endpoint Configuration
Endpoints are registered at the project level via the dashboard or API. Each endpoint subscribes to one or more event types.
Configuration fields:
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | HTTPS URL to receive POST requests |
secret |
string | Auto-generated | Signing secret for HMAC verification |
enabled_events |
list | Yes | Event types to deliver. Supports exact types (test_case.updated), resource wildcards (test_case.*), or * for all events. |
description |
string | No | Human-readable label (e.g., “JIRA ticket creator”) |
enabled |
boolean | Yes | Toggle delivery without deleting the endpoint |
A project can have multiple endpoints. Each endpoint receives only the events it subscribes to. Maximum of 16 endpoints per project.
Request Format
Headers:
| Header | Description |
|---|---|
Content-Type |
application/json |
Tuist-Signature |
t=<unix_timestamp>,v1=<hmac_sha256_hex> |
Tuist-Event-Id |
The event id (for deduplication) |
Tuist-Event-Type |
The event type (for routing without parsing the body) |
User-Agent |
Tuist-Webhooks/1.0 |
Signature construction (following Stripe’s scheme):
- Concatenate the Unix timestamp, a
.character, and the raw JSON request body:{timestamp}.{payload} - Compute HMAC-SHA256 using the endpoint’s signing secret as the key.
- The consumer verifies by recomputing the HMAC and comparing in constant time. Reject events where the timestamp is more than 5 minutes old to prevent replay attacks.
Delivery Behavior
| Property | Value |
|---|---|
| Retry strategy | Exponential backoff: 1m, 5m, 30m, 2h, 8h, 24h |
| Success condition | Any 2xx response within 10 seconds |
| Failure conditions | Non-2xx status, timeout, TLS error, DNS failure |
| Ordering | Not guaranteed. Consumers must not depend on event order. |
| Idempotency | Events include a stable id. Consumers should deduplicate on Tuist-Event-Id. |
| Delivery log | Dashboard shows last 30 days of deliveries per endpoint with status, latency, and manual redeliver button. |
Full Example: test_case.updated Event (transition to muted)
A test case transitioning from enabled to muted. Consumers detect the transition by comparing object.state against previous_attributes.state.
{
"id": "evt_1RqK4hJf2a9B7cXy",
"type": "test_case.updated",
"created": 1744206720,
"project": {
"id": "proj_abc123",
"full_name": "tuist/tuist"
},
"object": {
"id": "tc_xyz789",
"object": "test_case",
"name": "test_cache_warm_with_external_only",
"suite": "CacheWarmServiceTests",
"module": "TuistKitTests",
"state": "muted",
"labels": ["flaky"],
"url": "https://tuist.dev/tuist/tuist/test-cases/tc_xyz789"
},
"previous_attributes": {
"state": "enabled",
"labels": []
},
"request": {
"id": "auto_flaky_eval_20260411"
}
}
Full Example: test_case.updated Event (recovery to enabled)
The same event type covers the recovery case. The transition direction is visible from previous_attributes.
{
"id": "evt_2TsM5kLg3b0C8dZw",
"type": "test_case.updated",
"created": 1744212120,
"project": {
"id": "proj_abc123",
"full_name": "tuist/tuist"
},
"object": {
"id": "tc_xyz789",
"object": "test_case",
"name": "test_cache_warm_with_external_only",
"suite": "CacheWarmServiceTests",
"module": "TuistKitTests",
"state": "enabled",
"labels": [],
"url": "https://tuist.dev/tuist/tuist/test-cases/tc_xyz789"
},
"previous_attributes": {
"state": "muted",
"labels": ["flaky"]
},
"request": {
"id": "auto_recovery_eval_20260411"
}
}
Future Example: build.updated Event
To illustrate extensibility, this uses the same envelope with a different object payload:
{
"id": "evt_9XnP3qRs7d4E1fAv",
"type": "build.updated",
"created": 1744210000,
"project": {
"id": "proj_abc123",
"full_name": "tuist/tuist"
},
"object": {
"id": "build_def456",
"object": "build",
"state": "failed",
"duration_seconds": 342,
"cache_hit_rate": 78.5,
"target_count": 42,
"error_count": 3,
"url": "https://tuist.dev/tuist/tuist/builds/build_def456"
},
"previous_attributes": {
"state": "running",
"duration_seconds": null,
"error_count": 0
},
"request": {
"id": "req_cli_upload_xyz"
}
}
JIRA Integration Example
A Cloudflare Worker that creates JIRA tickets for silenced tests:
export default {
async fetch(request, env) {
// 1. Verify signature
const signature = request.headers.get("Tuist-Signature");
const body = await request.text();
if (!verifySignature(body, signature, env.TUIST_WEBHOOK_SECRET)) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(body);
// 2. Deduplicate
const seen = await env.KV.get(`evt:${event.id}`);
if (seen) return new Response("Already processed", { status: 200 });
// 3. Handle test silencing events. We listen to test_case.updated and
// detect a transition into a silenced state by comparing previous_attributes
// against the new object state. This naturally fires once per actual
// transition, regardless of how many subsequent updates touch the resource.
const prev = event.previous_attributes ?? {};
const becameSilenced =
event.type === "test_case.updated" &&
"state" in prev &&
prev.state === "enabled" &&
(event.object.state === "muted" || event.object.state === "skipped");
if (becameSilenced) {
const tc = event.object;
await fetch("https://your-org.atlassian.net/rest/api/3/issue", {
method: "POST",
headers: {
"Authorization": `Basic ${btoa(`[email protected]:${env.JIRA_API_TOKEN}`)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
fields: {
project: { key: "MOBILE" },
issuetype: { name: "Bug" },
summary: `Flaky test silenced: ${tc.suite}/${tc.name}`,
description: {
type: "doc",
version: 1,
content: [{
type: "paragraph",
content: [{
type: "text",
text: [
`Test "${tc.name}" in ${tc.module} was ${tc.state}.`,
`Dashboard: ${tc.url}`,
].join(" "),
}],
}],
},
labels: ["flaky-test", "auto-created"],
},
}),
});
}
// 4. Mark as processed
await env.KV.put(`evt:${event.id}`, "1", { expirationTtl: 86400 * 30 });
return new Response("OK", { status: 200 });
},
};
function verifySignature(payload, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=", 2))
);
const signed = `${parts.t}.${payload}`;
const encoder = new TextEncoder();
// Use Web Crypto API for HMAC-SHA256 verification
// (simplified. production code should use constant-time comparison)
return crypto.subtle.verify(
"HMAC",
crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]),
hexToBuffer(parts.v1),
encoder.encode(signed)
);
}
Relationship to Automations Evolution
The Automations Evolution RFC proposes composable automation actions including send_webhook. The two systems are complementary:
- This RFC defines the webhook infrastructure: event envelope format, endpoint management, signing, delivery, and retries.
- Automations define the triggers: when conditions are met, an automation can fire a
send_webhookaction that delivers through this infrastructure.
The event format and delivery mechanics described here are designed to be reused by the automations system. An automation’s send_webhook action would produce an event with the same envelope, deliver it through the same retry pipeline, and show up in the same delivery log.
Alternatives Considered
1. Direct JIRA / Linear integrations
Building first-party integrations provides a smoother setup experience but does not scale. Every new tool requires dedicated development and maintenance. A webhook is the universal building block that unlocks all integrations. We still should consider adding first-party integrations for the most common tools used by teams, but webhooks ensure that teams can ingest data and integrate with any system before we might add support for it.
2. Polling the API
Teams could poll Tuist’s API for state changes. This is inefficient, introduces latency, and puts unnecessary load on the API. Push-based webhooks are the industry standard for event-driven integrations.
3. Entity-specific webhook designs
We could design a bespoke webhook payload for test silencing events, another for builds, another for cache, etc. This leads to inconsistent consumer code and more maintenance. The uniform Stripe-style envelope means one parser handles all entity types.
Open Questions
- Batch events. When an automation silences 50 tests at once, should we send 50 individual events or allow a batch envelope? Individual events are simpler and match Stripe’s model, but a batch mode could reduce noise for consumers that want to aggregate (e.g., one JIRA ticket for all tests silenced in a single action). I’d personally start with sending individual events and we can add batching down the line.