Gareth Wilson Gareth Wilson

Guide to Omnara Webhooks: Features and Best Practices

Published


Omnara 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, 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 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

FeatureDetails
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature
BodyJSON
Event routingBody field event
ScopePer agent config (event_webhook); self-subagents inherit it
Event selectionRequired, nonempty events allowlist
SigningOpt-in, via event_webhook.signing_secret_id
Secret formatBare base64 (for example from openssl rand -base64 32)
Delivery windowQueued for up to 10 minutes, 5-second request timeout
RetriesExponential backoff with jitter inside the window
GuaranteesAt least once, unordered
Source IPsNot published

Events and payloads

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

EventTriggered WhenCommon Use Cases
agent_inputInput is added to an agent's timeline (input_kind, content_blocks)Audit trails, mirroring conversations
model_outputThe model produces output (stop_reason, content_blocks)Logging responses, cost and quality tracking
tool_resultA tool call finishes (tool_call_id, outcome, content_blocks)Monitoring tool outcomes
context_checkpointThe context is summarized (summary, summarized_through_event_sequence)Tracking context compaction
tool_call_updateA 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:

{
  "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, and keep a copy server-side as OMNARA_WEBHOOK_SECRET.

  3. Add the webhook to the agent config, referencing the secret's ID:

    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 and put the URL it prints in event_webhook.url:

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:

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:

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 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 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 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 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 and point an agent's webhook at an Omnara source.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.