Triggers are the event-side counterpart to canonical tools. A trigger definition gives each provider event a stable name and a schema-validated canonical payload. A subscription binds that trigger to one project user, an optional named connection, and one or more webhook endpoints.

The MVP supports two flows:

TriggerIngestionDefault cadencePortable filters
gmail.email_receivedPolling60s (30s minimum)from, to, subjectContains
slack.message_receivedPushProvider callbackconversationId, from

Discover definitions locally with the SDK:

ts
import { Eyeball } from "@eyeball/sdk";

const eyeball = new Eyeball({
  apiKey: process.env.EYEBALL_API_KEY!,
  baseUrl: process.env.EYEBALL_EXECUTOR_URL!,
});

const triggers = await eyeball.triggers.list({
  toolkits: ["gmail", "slack"],
});

Create the destination first

A subscription references existing project webhook endpoints. Subscribe the endpoint to the exact trigger event or to trigger.*:

ts
import { Eyeball } from "@eyeball/sdk";

const eyeball = new Eyeball({
  apiKey: process.env.EYEBALL_API_KEY!,
  baseUrl: process.env.EYEBALL_EXECUTOR_URL!,
});

const endpoint = await eyeball.webhooks.create({
  url: "https://example.com/webhooks/eyeball",
  events: ["trigger.slack.message_received"],
});

Store endpoint.secret immediately. Trigger delivery uses the same signed envelope, verification helper, retry schedule, and delivery log as execution webhooks.

Subscribe to Slack push events

ts
import { Eyeball } from "@eyeball/sdk";

const eyeball = new Eyeball({
  apiKey: process.env.EYEBALL_API_KEY!,
  baseUrl: process.env.EYEBALL_EXECUTOR_URL!,
});

const subscription = await eyeball.subscriptions.create({
  trigger: "slack.message_received",
  userId: "user_123",
  connectionId: "conn_slack_123",
  webhookEndpointIds: ["whe_123"],
  filters: { conversationId: "C012345" },
});

console.log(subscription.ingestUrl);

Configure the returned ingestUrl as the Slack Events API request URL. Slack first sends url_verification; Eyeball echoes its challenge and does not emit an event. Later event_callback messages are normalized and delivered as trigger.slack.message_received.

The MVP authenticates the callback with that unguessable per-subscription secret. Slack request-signature verification is deferred; keep the URL private and recreate the subscription if it is exposed.

Subscribe to Gmail polling

ts
import { Eyeball } from "@eyeball/sdk";

const eyeball = new Eyeball({
  apiKey: process.env.EYEBALL_API_KEY!,
  baseUrl: process.env.EYEBALL_EXECUTOR_URL!,
});

const endpoint = await eyeball.webhooks.create({
  url: "https://example.com/webhooks/eyeball",
  events: ["trigger.gmail.email_received"],
});

await eyeball.subscriptions.create({
  trigger: "gmail.email_received",
  userId: "user_123",
  connectionId: "conn_gmail_123",
  webhookEndpointIds: [endpoint.endpointId],
  pollIntervalSeconds: 60,
  filters: { subjectContains: "invoice" },
});

The scheduler stores a Gmail historyId high-water cursor. The first due poll emits matching inbox messages currently visible to the adapter. Later polls emit only messages beyond that cursor. A failed poll retains the prior cursor and schedules another attempt.

Canonical event envelope

Both flows arrive through your endpoint in this shape:

JSON
{
  "id": "evt_...",
  "type": "trigger.slack.message_received",
  "createdAt": "2026-07-18T09:00:00.000Z",
  "projectId": "proj_...",
  "data": {
    "subscriptionId": "trgsub_...",
    "trigger": "slack.message_received",
    "userId": "user_123",
    "connectionId": "conn_slack_123",
    "providerEventId": "Ev012345",
    "occurredAt": "2026-07-18T08:59:59.000Z",
    "payload": {
      "id": "msg_123",
      "from": "U012345",
      "conversationId": "C012345",
      "text": "Invoice approved",
      "receivedAt": "2026-07-18T08:59:59.000Z",
      "x_provider": {
        "slack": {
          "eventId": "Ev012345",
          "teamId": "T012345",
          "channelId": "C012345",
          "eventTs": "1784365199.000000"
        }
      }
    }
  }
}

Portable fields stay at data.payload. Provider-only metadata is confined to the schema-validated x_provider.<toolkit> object. Credentials, ingest secrets, and polling cursors never enter the event.

Redacted event history

The signed webhook envelope above and queryable trigger history serve different purposes. The envelope is the payload-bearing event delivered to your receiver. GET /v1/trigger-events and eyeball.triggerEvents.list() return metadata-only arrival records for recent operator debugging; the history store cannot reconstruct a signed webhook body.

Every normalized arrival that passes identity, timestamp, filter, and catalog-payload validation gets an arrivalId. The first arrival is accepted and can create webhook work. A repeated provider observation inside the dedup window gets a different arrivalId, shares the accepted arrival's stable eventId, and is recorded as duplicate / not_enqueued. Provider event IDs and payload bodies are used transiently for validation, deduplication, ID derivation, and the outbound envelope, but are not persisted in trigger history.

History distinguishes two target sets:

  • requestedWebhookEndpointIds is the subscription's configured endpoint-ID snapshot when the arrival was admitted.
  • deliveryTargets contains only endpoint and delivery IDs actually materialized after the webhook worker rechecked endpoint existence, activity, and event subscriptions.

The event-level aggregate follows the authoritative webhook delivery records: selection can be selecting or no_targets; active work can be pending or delivering; terminal work can be succeeded, failed, or partial; and admission failures are admission_failed. Per-endpoint delivery history remains the place for attempt timing, status codes, and bounded transport errors. Trigger history never copies endpoint URLs, headers, request/response bodies, signatures, credentials, ingest secrets, provider cursors, or provider message content.

Recent history is retained for seven days, separately from the 24-hour provider dedup claim. It survives subscription and endpoint deletion until expiry. With EYEBALL_DATABASE_URL it survives executor restarts; the zero-config history is process-local. Logical expiry is immediate, while bounded background sweeps reclaim physical rows.

This history does not make delivery exactly once. Outbound delivery remains at least once, and the dedup claim, webhook admission, and history append are separate operations rather than one transaction. Replay/backfill and durable trigger-source-body reconstruction remain separate work.

Deduplication versus delivery retries

Eyeball claims (subscriptionId, providerEventId) for 24 hours. Repeated Slack callbacks or repeated Gmail observations inside that window enqueue one trigger event. This is a best-effort exactly-once ingestion window. Without EYEBALL_DATABASE_URL, the state store is process-local and loses claims on restart; the Postgres state store retains them. A crash between the dedup claim and durable webhook admission still requires a future transactional claim/outbox boundary for a closed exactly-once ingestion window.

Outbound webhook delivery is separately at least once. A successful handler whose acknowledgement is lost can receive the same envelope again. Verify the signature over the exact raw body, persist the envelope id, and apply the event idempotently.

ts
import { verifyWebhookSignature } from "@eyeball/sdk";

export async function POST(request: Request): Promise<Response> {
  const rawBody = await request.text();
  if (!verifyWebhookSignature({
    payload: rawBody,
    headers: request.headers,
    secret: process.env.EYEBALL_WEBHOOK_SECRET!,
  })) {
    return new Response("Invalid signature", { status: 401 });
  }

  return new Response(null, { status: 204 });
}

See the Triggers API for request and response shapes and Webhooks for signatures, retries, and delivery durability.