# Guide to Omnara Webhooks: Features and Best Practices

[Omnara](https://omnara.com) runs hosted AI agents. Each agent can be configured with an `event_webhook` that streams what happens in its timeline, such as the input it received, the model's output, tool results and context summaries, to an HTTPS endpoint you control. The same webhook also announces tool-call updates, which is how you run custom tools or handle approvals outside Omnara.

Below: the per-agent webhook configuration, the five event types, opt-in signing, and the short ten-minute delivery window you need to plan around.

## What are Omnara webhooks?

An Omnara event webhook belongs to an agent, not to the account. It lives in the agent config described in Omnara's [configuration reference](https://docs.omnara.com/agents/configuration), and lists the URL, the event types to send and, optionally, a signing secret. Self-subagents inherit their parent's webhook and send events under their own agent IDs. Other subagents use the configuration from their own profile.

Every delivery is a JSON `POST` with two fields: `event`, the type, and `data`, whose shape depends on that type. Omnara's [webhook page](https://docs.omnara.com/events/webhooks) treats the four timeline types as records of the agent's history and `tool_call_update` as a prompt to go and fetch the current state of a tool call.

## Omnara webhook features

| Feature | Details |
| --- | --- |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `webhook-signature` |
| Body | JSON |
| Event routing | Body field `event` |
| Scope | Per agent config (`event_webhook`); self-subagents inherit it |
| Event selection | Required, nonempty `events` allowlist |
| Signing | Opt-in, via `event_webhook.signing_secret_id` |
| Secret format | Bare base64 (for example from `openssl rand -base64 32`) |
| Delivery window | Queued for up to 10 minutes, 5-second request timeout |
| Retries | Exponential backoff with jitter inside the window |
| Guarantees | At least once, unordered |
| Source IPs | Not published |

## Events and payloads

`event_webhook.events` must list at least one of these five types:

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `agent_input` | Input is added to an agent's timeline (`input_kind`, `content_blocks`) | Audit trails, mirroring conversations |
| `model_output` | The model produces output (`stop_reason`, `content_blocks`) | Logging responses, cost and quality tracking |
| `tool_result` | A tool call finishes (`tool_call_id`, `outcome`, `content_blocks`) | Monitoring tool outcomes |
| `context_checkpoint` | The context is summarized (`summary`, `summarized_through_event_sequence`) | Tracking context compaction |
| `tool_call_update` | A tool call or interaction changes state (`state`, optional `interaction_update`) | Custom tool execution, approvals |

Omnara gives each type an example but no prose description, so the "triggered when" column above is read from those examples.

The four timeline events share a common set of fields in `data`: `id`, `org_id`, `project_id`, `agent_id`, `turn_id`, `turn_sequence`, `is_opening_event`, `sequence`, `event_kind` (which repeats `event`) and `created_at`, followed by the fields specific to the type. This `agent_input` sample comes from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/omnara/latest/agent_input.json):

```json
{
  "event": "agent_input",
  "data": {
    "id": "evt_5n6a2bfgik7mv4qtrwz3jehcyd",
    "org_id": "org_5n6a2bfgik7mv4qtrwz3jehcyd",
    "project_id": "proj_5n6a2bfgik7mv4qtrwz3jehcyd",
    "agent_id": "agt_5n6a2bfgik7mv4qtrwz3jehcyd",
    "turn_id": "trn_5n6a2bfgik7mv4qtrwz3jehcyd",
    "turn_sequence": 1,
    "is_opening_event": true,
    "sequence": 1,
    "event_kind": "agent_input",
    "agent_input_id": "ain_5n6a2bfgik7mv4qtrwz3jehcyd",
    "input_kind": "content",
    "content_blocks": [
      {
        "type": "text",
        "text": "Summarize the report."
      }
    ],
    "created_at": "2026-09-18T12:00:00Z"
  }
}

```

`tool_call_update` works differently. It tells you that a tool call or interaction changed, and you fetch the tool call to get its current details. If you execute custom tools, act on updates in the `ready` state and deduplicate by tool-call ID. When a permission request or question opens or closes, `interaction_update` carries its ID, kind and state (`open`, `resolved` or `canceled`). If the field is missing, the interaction didn't change. A terminal update wins over an `open` update that arrives late.

Omnara's SDK exports a zod schema, `zEventWebhookPayload`, which you can use to validate bodies after verification.

## Setting up Omnara webhooks

1. Decide whether to sign. For anything beyond a throwaway test, do: generate a value with `openssl rand -base64 32`.
2. Store that value in Omnara as a project-available [generic secret](https://docs.omnara.com/organization/secrets.md), and keep a copy server-side as `OMNARA_WEBHOOK_SECRET`.
3. Add the webhook to the agent config, referencing the secret's ID:
  
  
  ```yaml
  event_webhook:
    url: https://example.com/webhooks/omnara
    events: [agent_input, model_output, tool_result, context_checkpoint, tool_call_update]
    signing_secret_id: sec_xxxxxxxx   # the generic secret from step 2
  
  ```
  
  
  
  The URL must be public HTTPS. The config builder selects tool-call updates by default, so add the timeline types yourself if you want them. Edits only affect events queued after the change.
4. Run the agent and check that a delivery arrives with all three `Webhook-*` headers.

While developing, forward deliveries to your machine with the [Hookdeck CLI](/docs/cli) and put the URL it prints in `event_webhook.url`:

```bash
npx hookdeck-cli listen 3000 omnara --path /webhooks/omnara

```

**With Hookdeck.** Add a source of type **Omnara** and point the agent's `event_webhook.url` at it. Signing is opt-in per agent: set event_webhook.signing_secret_id, then paste that secret here as whsec_ followed by the secret. Unsigned deliveries fail verification. If an agent has no signing secret, leave verification off on its source rather than letting every event fail. The source only takes `POST`, replies `200` within Omnara's five-second timeout, and logs a bad signature as a verification failure instead of delivering it.

## Securing Omnara webhooks

Omnara follows the Standard Webhooks specification. The signed string is `Webhook-Id`, a dot, `Webhook-Timestamp`, a dot and the raw body, and the HMAC-SHA256 key is the base64-decoded secret. `Webhook-Signature` holds one or more space-separated `v1,<base64>` values. Omnara's secret has no `whsec_` prefix, which this function handles because it strips the prefix only when present:

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

A receiver for an agent that streams both timeline events and tool-call updates could look like this:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

const app = express();

app.post('/webhooks/omnara', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.OMNARA_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const payload = parseJsonObject(req.body);
  if (!payload) return res.sendStatus(400); // signed, but not a JSON object
  const { event, data } = payload;
  if (typeof event !== 'string' || !data || typeof data !== 'object') return res.sendStatus(400);
  const delivery_id = req.headers['webhook-id'];

  try {
    switch (event) {
      case 'tool_call_update':
        // Only a pointer: fetch the tool call later, keyed on its tool-call ID.
        await jobs.enqueue('omnara-tool-call', { delivery_id, data });
        break;
      case 'agent_input':
      case 'model_output':
      case 'tool_result':
      case 'context_checkpoint':
        await timeline.record(delivery_id, data.agent_id, data.sequence, data);
        break;
      default:
        break;
    }
  } catch {
    return res.sendStatus(503); // not processed, so don't acknowledge
  }
  return res.sendStatus(204);
});

```

`jobs` and `timeline` stand for your own queue and storage. Respond quickly: Omnara abandons a request after five seconds, so the actual tool work belongs in a background job.

Omnara's own example verifies with the `standardwebhooks` package, which refuses timestamps more than five minutes from the current time. Against Omnara directly that is harmless, because each retry carries a fresh `Webhook-Timestamp`. Hookdeck's retries and replays keep the timestamp from the original delivery and Hookdeck checks no age at all, so behind Hookdeck either drop the library's age check or use a function like the one above.

> **Make Omnara webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) accepts each agent event inside Omnara's five-second timeout and holds it until your service has processed it, well past Omnara's ten-minute window.

## Omnara webhook limitations and pain points

### Unsigned unless you opt in

**The Problem:** Deliveries arrive without a `Webhook-Signature` header, and anyone who learns the URL could send look-alike events.

**Why It Happens:** Signing is configured per agent and is off until `event_webhook.signing_secret_id` is set. Unsigned requests still carry `Webhook-Id` and `Webhook-Timestamp`, which makes them easy to mistake for signed ones.

**Workarounds:**

* Set a signing secret on every agent with a webhook, including ones created from templates.
* Reject requests with no `webhook-signature` rather than skipping the check.

**How Hookdeck Can Help:** A Hookdeck Omnara source with verification on records unsigned deliveries as failed, which shows you which agents still lack a secret.

### A ten-minute delivery window

**The Problem:** A deploy or outage of more than a few minutes loses events.

**Why It Happens:** Omnara queues each delivery for at most ten minutes and retries only inside that window. Each request also times out after five seconds.

**Workarounds:**

* Acknowledge first and process in a background job.
* Track `sequence` per agent so you can see which timeline events went missing during an outage.

**How Hookdeck Can Help:** Hookdeck acknowledges Omnara straight away and keeps [retrying](/docs/retries) your service on its own schedule, so the ten-minute limit only applies to reaching Hookdeck.

### Unordered, at-least-once delivery

**The Problem:** A `model_output` can arrive before the `agent_input` that led to it, and the same event can arrive twice.

**Why It Happens:** Omnara documents delivery as at least once with no ordering guarantee.

**Workarounds:**

* Store timeline events keyed on `agent_id` and `sequence`, and sort when reading.
* Deduplicate on `Webhook-Id`, which stays the same across retries, and tool work on the tool-call ID.

**How Hookdeck Can Help:** [Deduplication](/docs/deduplication) rules can drop repeated deliveries before they reach your service.

### Bare base64 secrets

**The Problem:** Verification fails even though the secret was copied correctly.

**Why It Happens:** Omnara secrets have no `whsec_` prefix. Tools that only base64-decode prefixed secrets, Hookdeck's source among them, will use the text itself as the key unless you add the prefix.

**Workarounds:**

* Paste the secret into Hookdeck as `whsec_` followed by the value. Your own code can decode the bare value.

**How Hookdeck Can Help:** Once the prefixed secret is saved on the source, verification happens there and downstream services don't need the secret at all.

### Config changes aren't retroactive

**The Problem:** You add an event type or a signing secret and older events still arrive the old way.

**Why It Happens:** Changes to the config apply only to events queued after the change.

**Workarounds:**

* Expect a short overlap after any edit, and keep accepting the old shape until queued events drain, which takes at most ten minutes.

**How Hookdeck Can Help:** Hookdeck's request log shows exactly when the new headers or types started arriving.

## Best practices

### Subscribe narrowly

The timeline types can be busy on long-running agents. If you only run custom tools, `tool_call_update` alone may be enough, and that is what the config builder selects.

### Treat `tool_call_update` as a pointer

Fetch the tool call before acting, handle the `ready` state, and let a terminal interaction state override a late `open`.

### Validate after verifying

Check the signature against the raw bytes, then parse and validate the body, for example with Omnara's `zEventWebhookPayload` schema. Our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency) covers how to store `Webhook-Id` alongside the resulting change.

### Use one secret per environment

Generic secrets are project-scoped, so keep separate projects or secrets for staging and production, and rotate by updating the secret in Omnara and your receiver together.

## Conclusion

Omnara's event webhooks give you a live feed of what an agent is doing and a hook for running tools outside the platform. Turn signing on for every agent, add the `whsec_` prefix where your tooling expects one, answer within five seconds, and design for unordered, repeated events that stop being retried after ten minutes.

Hookdeck takes care of the short timeout and the brief retry window. [Create a free account](https://dashboard.hookdeck.com/signup) and point an agent's webhook at an Omnara source.