Webhooks deliver terminal execution, selected voice-session, and normalized trigger events. Delivery is asynchronous: a slow or unavailable receiver never delays the source workflow.

Create an endpoint

Use an unpinned project API key. The endpoint must be an HTTPS URL and must subscribe to at least one event.

Bash
curl --request POST "$EYEBALL_EXECUTOR_URL/v1/webhooks" \
  --header "Authorization: Bearer $EYEBALL_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "url": "https://example.com/webhooks/eyeball",
    "events": ["execution.completed", "voice.session.event"],
    "active": true
  }'

The creation response contains a whsec_... signing secret. It is returned exactly once. Persist it in your secret manager; later list and get responses expose only secretPrefix.

You can perform the same endpoint creation and event selection from the selected project's Webhooks screen in the dashboard. The screen also provides endpoint settings, reveal-once rotation, confirmed deletion, and delivery-attempt inspection.

Event types

SubscriptionDelivered envelope typeAvailability
execution.completedexecution.succeeded, execution.failed, or execution.cancelledConvenience selector for every terminal outcome.
execution.succeededexecution.succeededEmitted after a successful execution record is durable.
execution.failedexecution.failedEmitted after a failed execution record is durable.
execution.cancelledexecution.cancelledEmitted after a cancelled execution record is durable, with its immutable dispatch disposition.
voice.session.eventvoice.session.eventCarries a selected durable VoiceAgentSessionEvent. Both the scripted development driver and configured remote worker feed this delivery seam.
voice.transcript.readyvoice.transcript.readyEmitted after a configured remote worker observes a terminal session and materializes its normalized transcript.
voice.observer.failedvoice.observer.failedExecutor-owned terminal signal that durable observation exhausted its retry budget or rejected a non-retryable worker response. It reports the pinned agent, last handled sequence, attempts, safe operation/reason, and normalized error without changing worker session state.
trigger.*Any trigger.<toolkit>.<name>Wildcard selector for every trigger directed to the endpoint.
trigger.<toolkit>.<name>The same exact event typeSelects one canonical trigger, such as trigger.gmail.email_received.

Voice-agent definitions still select granular session events such as session.lifecycle, tool_call, and tool_result, and reference one or more project endpoint IDs. The endpoint itself subscribes to the outer voice.session.event class.

Remote-worker events remain authoritative in the worker. With Postgres, the executor persists a lease-fenced observer cursor/retry state and source-first voice envelopes, then reconciles unfinished sessions at startup. The normal poll resumes after the last durably handled sequence, while terminal transcript finalization rereads the complete worker history. Without Postgres, the observer and voice sources remain process-local.

Every request body uses this envelope:

ts
type WebhookEvent = {
  id: string;
  type:
    | "execution.succeeded"
    | "execution.failed"
    | "execution.cancelled"
    | "voice.session.event"
    | "voice.transcript.ready"
    | "voice.observer.failed"
    | `trigger.${string}.${string}`;
  createdAt: string;
  projectId: string;
  data: unknown;
};

Verify the signature

Read the exact raw request body before parsing JSON. The SDK helper checks the HMAC in constant time and rejects timestamps more than five minutes from the current time.

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

export async function POST(request: Request): Promise<Response> {
  const payload = await request.text();
  const valid = verifyWebhookSignature({
    payload,
    headers: request.headers,
    secret: process.env.EYEBALL_WEBHOOK_SECRET!,
  });

  if (!valid) {
    return new Response("Invalid webhook signature", { status: 401 });
  }

  const event = JSON.parse(payload) as { id: string; type: string };
  await handleWebhook(event);
  return new Response(null, { status: 204 });
}

async function handleWebhook(event: { id: string; type: string }): Promise<void> {
  // Persist event.id and apply the event in one idempotent transaction.
  console.log(event.type, event.id);
}

Eyeball signs the UTF-8 bytes of <timestamp>.<raw-body> with HMAC-SHA256. The timestamp is decimal Unix seconds and the signature is v1=<64 lowercase hexadecimal characters>.

HeaderMeaning
Eyeball-Webhook-IdStable event ID used for receiver deduplication.
Eyeball-Webhook-TimestampDecimal Unix time in seconds.
Eyeball-Webhook-SignatureHMAC value in v1=<hex> form.

For compatibility with the original RFC 001 names, the sender also emits Eyeball-Timestamp and Eyeball-Signature; the SDK accepts either pair.

Acknowledgement and retries

Any 2xx response acknowledges the event. Each receiver request has a 10-second default timeout. A non-2xx response, timeout, or transport failure uses this fixed, bounded schedule:

AttemptDelay before attemptEarliest cumulative time
1Immediate0s
230s30s
32m2m 30s
410m12m 30s
51h1h 12m 30s

After attempt five, the delivery is marked failed. Inspect attempts, status codes, and nextRetryAt with GET /v1/webhooks/:endpointId/deliveries or the project's Webhooks → Deliveries tab. Delivery inspection contains metadata only, never the delivered payload, signing secret, request/response bodies, or receiver headers.

Every attempt resolves the endpoint's current URL, signing secret, and active state. This includes retries that were queued before a settings change: URL changes affect the next attempt, secret rotation invalidates the old secret immediately and pending retries use the replacement, and deactivation or deletion prevents unfinished work from continuing.

Delivery is at least once. Store processed event IDs and make handlers idempotent, because an acknowledgement can be lost after your handler commits its work.

Ordering and durability

Eyeball processes one delivery at a time per endpoint. Retries for an earlier event finish or exhaust before a later event is attempted on that same endpoint. This is best-effort per-endpoint order, not global order: different endpoints progress independently, and duplicate delivery remains possible.

Without EYEBALL_DATABASE_URL, the stock endpoint, delivery, work, job, voice-observer, and voice-source stores are process-local and a restart loses pending work. With Postgres, execution envelopes reconstruct from durable execution records and voice envelopes reconstruct from voice_webhook_sources; trigger payloads remain process-local until their own durable source work ships. Deterministic ID-only jobs retain retry deadlines and restore unfinished selection/delivery before task workers claim after restart. Actual HTTP delivery remains at least once, so production self-hosters still need delivery metrics, exhausted-attempt alerts, backups, restore drills, and receiver-side event-ID deduplication.

See the webhook API reference for route shapes and Triggers for subscription and payload semantics.