RFC: Webhooks for Tuist Events

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:

  1. 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.
  2. Manual toil. Engineers context-switch between the Tuist dashboard and their ticketing system, copying names, failure details, and links by hand.
  3. Inconsistent tracking. Different team members use different formats, labels, or priorities when filing tickets manually.
  4. 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.action naming (e.g., customer.created, invoice.payment_failed). Readable, greppable, and naturally hierarchical for wildcard subscriptions.
  • Snapshot payloads. object contains the full resource state at event time. Consumers do not need to call back to the API to get context.
  • previous_attributes. For *.updated events, 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):

  1. Concatenate the Unix timestamp, a . character, and the raw JSON request body: {timestamp}.{payload}
  2. Compute HMAC-SHA256 using the endpoint’s signing secret as the key.
  3. 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_webhook action 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

  1. 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.

Very onboard with this. Can’t think of a platform for developers that doesn’t add webhooks sooner or later. There are a couple of things that came to my mind while reading this.

Storage

When building on GitHub webhooks, I found it useful to have a UI that shows the delivered webhooks (request headers, body, response…), with an option to resend them, for example, to confirm that a fix on their end works. Is this something you’d scope as part of this initial work? Or at least store them, and we can build this UI in a follow-up.

Local development

When building an integration with webhooks, it’s very convenient to forward them to a locally running instance of your server, so I was thinking we could make this convenient for users through the CLI. Otherwise, they have to bring an additional tunnel, which adds unnecessary friction in the process of building on us.

Grouping

How would you model this from the Elixir side? Were you thinking about creating an Oban worker per event, and then letting Oban run those? If so, I was thinking whether it’d make sense to do some batching, not for the use case you mention above, but to make efficient use of the HTTP connections. For example, if we receive 100 events going to the same URL in a short period of time, we could warm a Finch pool of connections and schedule some of them for the same pool. We can also treat this as a future optimization.

I agree we should store them, and the UI can be added in a follow-up.

That’s a good idea :+1: We can and should do that.

Yes, I planned to use Oban workers. I think some batching might be useful, I’ll take that into account during the implementation and will see if, initially, we would just limit the worker concurrency and let those events wait in the Oban queue or if it would make sense to outright batch them.