# RFC: Webhooks for Tuist Events

**URL:** https://community.tuist.dev/t/rfc-webhooks-for-tuist-events/969
**Category:** RFCs
**Created:** [April 12, 2026, 1:13pm UTC](https://community.tuist.dev/t/rfc-webhooks-for-tuist-events/969 "2026-04-12T13:13:34Z")
**Posts on this page:** 3
**Page:** 1

<div class="post-metadata">

### Author: ![marekfort](https://community.tuist.dev/user_avatar/community.tuist.dev/marekfort/32/8_2.png) [@marekfort](https://community.tuist.dev/u/marekfort)
#### Post date: [April 12, 2026, 1:13pm UTC](https://community.tuist.dev/t/rfc-webhooks-for-tuist-events/969/1 "2026-04-12T13:13:34Z")

</div>

### 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](https://community.tuist.dev/t/automations-evolution/964) 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:

```json
{
  "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`.

```json
{
  "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`.

```json
{
  "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:

```json
{
  "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:

```javascript
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(`user@org.com:${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](https://community.tuist.dev/t/automations-evolution/964) 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.

---

<div class="post-metadata">

### Author: ![pepicrft](https://community.tuist.dev/user_avatar/community.tuist.dev/pepicrft/32/5_2.png) [@pepicrft](https://community.tuist.dev/u/pepicrft)
#### Post date: [April 12, 2026, 1:33pm UTC](https://community.tuist.dev/t/rfc-webhooks-for-tuist-events/969/2 "2026-04-12T13:33:29Z")

</div>

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.

---

<div class="post-metadata">

### Author: ![marekfort](https://community.tuist.dev/user_avatar/community.tuist.dev/marekfort/32/8_2.png) [@marekfort](https://community.tuist.dev/u/marekfort)
#### Post date: [April 12, 2026, 1:42pm UTC](https://community.tuist.dev/t/rfc-webhooks-for-tuist-events/969/3 "2026-04-12T13:42:50Z")

</div>

> [@pepicrft](#):
>
> Or at least store them, and we can build this UI in a follow-up.

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

> [@pepicrft](#):
>
> 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.

That’s a good idea 👍 We can and should do that.

> [@pepicrft](#):
>
> 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.

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.
