> Source: Onevium official documentation
> Article: Webhooks and callbacks: events, signatures, and delivery
> Original URL: https://onevium.com/docs/developers/webhooks
> Language: English
> Updated: 2026-09-09
> Applies to: 1.1.23+

---

# Webhooks and callbacks: events, signatures, and delivery

Configure incoming business events and outgoing callbacks, use signature examples, and verify deduplication and actual delivery.

## Separate input from result delivery

An **incoming Webhook** submits work when your business system emits an event. A **result callback** sends selected Onevium events to your receiver. They use different URLs and signing secrets and can be enabled independently.

Follow the ticket example below to configure and test both directions. Screenshots show real preview components with isolated example data, without live business requests.

## Prepare a complete test case

Follow [external access](https://onevium.com/docs/developers/external-access) to create an application, bind and validate an assistant, publish its service, and enable access. This example uses synthetic ticket `ticket-42` and fixed business subject `support-demo`. `gateway.example.com` and `hooks.example.com` are placeholders; replace them with addresses you control and have configured before sending.

Distinguish the addresses first. If the interface shows `http://127.0.0.1:18420/api/v1` as its API address, `GATEWAY_URL` in API examples is only `http://127.0.0.1:18420`: remove the trailing `/api/v1`. This page's `ONEVIUM_WEBHOOK_URL` is the **complete URL** on the Webhook card, retaining `/api/v1/webhooks/...` without appending another prefix. Callback URLs also retain their receiver path.

The flow is:

1. A ticket update emits `ticket.updated`.
2. The gateway matches or creates a conversation using `ticket_id` and returns an admission receipt.
3. Your backend stores the mapping from `ticket-42` to `conversation_id/run_id`.
4. The assistant runs; your backend retrieves the result through queries, SSE, or an optional callback.

Local systems can use queries or SSE directly. **Outgoing callbacks currently require an administrator-allowed public HTTPS hostname on default port 443. Localhost, private networks, and IP literals are blocked.** A public receiver is not a prerequisite for testing the API.

## Configure the incoming Webhook

Open Channels → External Access, select the application, and choose Events and callbacks → Add webhook. Enter:

![Upper webhook fields: choose a service, event type, business subject, and action.](https://onevium.com/docs/external-access/3cc390708/en/12-webhook-message.png "Upper webhook fields: choose a service, event type, business subject, and action.")

| Form field             | Example value             | Meaning                               |
| ---------------------- | ------------------------- | ------------------------------------- |
| Assistant service      | Published test assistant  | Which service handles the event       |
| Event type field       | `type`                    | Where to read the event type in JSON  |
| Event types            | `ticket.updated`          | Exact allowed values; comma-separated |
| Business subject       | `support-demo`            | Fixed identity, not a JSON field path |
| Event action           | Continue conversation     | The `message` action                  |
| Message field          | `text`                    | Text for the assistant                |
| Structured data field  | `data`                    | A JSON object in this example         |
| Resource IDs field     | Empty                     | No attachments in this example        |
| Conversation matching  | Business conversation key | Match by a business identifier        |
| Conversation key field | `ticket_id`               | Continue the ticket's conversation    |
| Enabled                | Checked                   | Accept events                         |

![Scrolled field detail: map message content and a business conversation key to continue a conversation.](https://onevium.com/docs/external-access/3cc390708/en/13-webhook-mapping.png "Scrolled field detail: map message content and a business conversation key to continue a conversation.")

Save and retain the generated signing secret. The application page displays the complete URL:

```text
POST <gateway origin without /api/v1>/api/v1/webhooks/<integration_id>
```

Copy that URL. Its final identifier is the Webhook's `integration_id`, not the assistant endpoint ID. The secret is not freely readable later; if lost, edit and rotate the signing secret, then update the sender.

Field paths use dot notation such as `data.text`, with up to five segments, not arbitrary JSONPath. Business subject is a fixed string; a body `subject` cannot override it. The event-type list must be nonempty. A nonmatching event receives 403 rather than being silently accepted.

### Other conversation actions

| Action                            | Additional fields                     | Behavior                                                                  |
| --------------------------------- | ------------------------------------- | ------------------------------------------------------------------------- |
| New conversation `new`            | Message/data/resource fields          | Each new event gets a conversation; cannot bind an existing key or ID     |
| Continue `message`, matched by ID | Conversation ID and generation fields | Both required; generation must be current                                 |
| Restart `reset`                   | ID, generation, and busy policy       | Reject restart or stop then restart; query the operation for confirmation |
| Stop run `cancel`                 | Run ID field                          | Only runs authorized for that service and subject                         |

![Restart field detail: configure conversation ID and generation field mappings, plus the running-task policy.](https://onevium.com/docs/external-access/3cc390708/en/14-webhook-reset.png "Restart field detail: configure conversation ID and generation field mappings, plus the running-task policy.")

Provide valid text, structured data, or resource input. Upload resources through the authorized API first. Do not map local paths or execution permissions from the payload.

## Send a correctly signed event

Save `ticket-event.json` on your backend:

```json
{
  "type": "ticket.updated",
  "ticket_id": "ticket-42",
  "text": "Summarize this test ticket and suggest the next check. Do not modify external systems.",
  "data": {
    "ticket_id": "ticket-42",
    "summary": "A test device cannot connect after restart"
  }
}
```

Create a private `webhook.env`, replacing both values with those from the interface:

```dotenv
ONEVIUM_WEBHOOK_URL=https://gateway.example.com/api/v1/webhooks/YOUR_INTEGRATION_ID
ONEVIUM_WEBHOOK_SECRET=PASTE_THE_INCOMING_WEBHOOK_SECRET
```

On the same machine, built-in access can use its displayed `http://127.0.0.1:actual-port` address. Remote access requires HTTPS. Keep the environment file out of Git.

This `send-ticket.mjs` uses Node.js 20.18 or later without extra dependencies:

```javascript
import { createHmac } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const url = new URL(process.env.ONEVIUM_WEBHOOK_URL);
const secret = process.env.ONEVIUM_WEBHOOK_SECRET;
const eventId = process.argv[2];
if (!secret || secret.length < 32) throw new Error('Missing webhook secret');
if (!/^[\x21-\x7e]{1,128}$/.test(eventId || '')) throw new Error('Invalid event ID');
if (url.protocol !== 'https:' &&
    !(url.protocol === 'http:' && url.hostname === '127.0.0.1')) {
  throw new Error('Use HTTPS, or the displayed local loopback gateway');
}
const body = await readFile('ticket-event.json');
if (body.length > 1024 * 1024) throw new Error('Payload exceeds 1 MiB');
JSON.parse(body.toString('utf8'));
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = createHmac('sha256', secret)
  .update(`${eventId}.${timestamp}.`).update(body).digest('hex');
const response = await fetch(url, {
  method: 'POST',
  redirect: 'error',
  signal: AbortSignal.timeout(30_000),
  headers: {
    'Content-Type': 'application/json',
    'webhook-id': eventId,
    'webhook-timestamp': timestamp,
    'webhook-signature': `v1,${signature}`,
  },
  body,
});
console.log(response.status, await response.text());
if (!response.ok) process.exitCode = 1;
```

```bash
node --env-file=webhook.env send-ticket.mjs ticket-42-version-1
```

The expected result is 202 with admission identifiers such as `conversation_id`, `generation`, `message_id`, and `run_id`. These are **expected checks**, not observed results from your deployment. A 202 means durably accepted, not completed.

## Signatures, replay, and duplicate events

Incoming and outgoing messages share a signature format but use separate secrets:

```text
Signed bytes = UTF-8(event_id + "." + timestamp + ".") + original body bytes
Signature = HMAC-SHA256(UTF-8 of the literal secret, signed bytes)
```

| Header              | Value                                                            |
| ------------------- | ---------------------------------------------------------------- |
| `webhook-id`        | Stable event ID; 1–128 printable ASCII characters without spaces |
| `webhook-timestamp` | Unix timestamp in seconds                                        |
| `webhook-signature` | `v1,` followed by 64 lowercase hexadecimal characters            |
| `Content-Type`      | `application/json`                                               |

Even if the generated secret looks hexadecimal, use its **literal text** rather than decoding it as hex. This contract does not claim Standard Webhooks SDK compatibility.

- The gateway permits at most 300 seconds of clock difference. Synchronize sender and receiver clocks.
- Retry the same business event with the same ID and original body. A fresh timestamp and signature are allowed.
- The same ID and identical raw body return the existing admission; a different body with that ID returns 409.
- Parsing and reserializing JSON can change whitespace, key order, or encoding. Verify against original bytes.
- Incoming bodies are limited to 1 MiB. Use new IDs for new business events or versions, not to poll progress.

Event deduplication is not cross-system exactly-once delivery. Persist a unique business-side event key and reconcile uncertain executions or external side effects.

## Configure outgoing result callbacks

### Allow the hostname first

For built-in access, open External access settings → Result callbacks. Enter an exact hostname such as `hooks.example.com` in Allowed callback hostnames and save. Separate multiple names with commas; omit schemes, ports, paths, and wildcards.

![Enter exact allowed callback hostnames; an empty list blocks callbacks. Local queries and SSE use their own authorization.](https://onevium.com/docs/external-access/3cc390708/en/10-callback-allowlist.png "Enter exact allowed callback hostnames; an empty list blocks callbacks. Local queries and SSE use their own authorization.")

**An empty list blocks every callback.** Even an allowed hostname is blocked if it resolves to private, loopback, or reserved IP addresses. For a remote gateway, configure `ONEVIUM_GATEWAY_CALLBACK_HOSTS` on the server; see [remote gateway](https://onevium.com/docs/developers/remote-gateway).

### Add the callback

Return to Events and callbacks → Add callback:

| Form field             | Example                                          |
| ---------------------- | ------------------------------------------------ |
| Service on this device | A test service for the target application/device |
| Callback URL           | `https://hooks.example.com/onevium/events`       |
| Subscribed events      | `run.completed, run.failed, approval.required`   |
| Enabled                | Checked                                          |

![Choose a callback URL and event subscriptions for this application on the selected device (example).](https://onevium.com/docs/external-access/3cc390708/en/11-result-callback.png "Choose a callback URL and event subscriptions for this application on the selected device (example).")

Save and retain the **callback signing secret**, separately from the incoming secret.

The service selection identifies the application and device. **The callback receives selected events from every service of that application on that device, not just the chosen assistant.** Resolve tickets through your stored `run_id/conversation_id` mapping. Do not assume events include `subject` or `app_id`.

## Copyable callback receiver

This example verifies signatures and stores events in a dedicated demo directory. **It does not update tickets and is not a production database adapter.** In a real integration, commit the unique event key and business state in one durable transaction before returning 2xx.

Save this as `callback-receiver.mjs` and set `ONEVIUM_CALLBACK_SECRET` in `callback.env` to the callback secret:

```javascript
import http from 'node:http';
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { mkdir, writeFile, readFile, link, unlink } from 'node:fs/promises';
import path from 'node:path';

const secret = process.env.ONEVIUM_CALLBACK_SECRET;
if (!secret || secret.length < 32) throw new Error('Missing callback secret');
const inbox = path.resolve('callback-inbox');
await mkdir(inbox, { recursive: true, mode: 0o700 });
const subscribed = new Set(['run.completed', 'run.failed', 'approval.required']);
const reject = (status) => Object.assign(new Error('Request rejected'), { status });

async function storeOnce(eventId, raw) {
  const name = createHash('sha256').update(eventId).digest('hex');
  const destination = path.join(inbox, `${name}.json`);
  const temporary = path.join(inbox, `${randomUUID()}.tmp`);
  try {
    await writeFile(temporary, raw, { flag: 'wx', mode: 0o600 });
    try {
      await link(temporary, destination);
    } catch (error) {
      if (error.code !== 'EEXIST') throw error;
      if (!(await readFile(destination)).equals(raw)) throw reject(409);
    }
  } finally {
    await unlink(temporary).catch(() => {});
  }
}

const server = http.createServer(async (request, response) => {
  try {
    if (request.method !== 'POST' || request.url !== '/onevium/events') {
      throw reject(404);
    }
    const id = request.headers['webhook-id'];
    const timestamp = request.headers['webhook-timestamp'];
    const supplied = request.headers['webhook-signature'];
    if (typeof id !== 'string' || !/^[\x21-\x7e]{1,128}$/.test(id) ||
        typeof timestamp !== 'string' || !/^\d{10,13}$/.test(timestamp) ||
        Math.abs(Date.now() / 1000 - Number(timestamp)) > 300 ||
        typeof supplied !== 'string' || !/^v1,[a-f0-9]{64}$/.test(supplied)) {
      throw reject(401);
    }
    const chunks = [];
    let size = 0;
    for await (const chunk of request) {
      size += chunk.length;
      if (size > 1024 * 1024) throw reject(413);
      chunks.push(chunk);
    }
    const raw = Buffer.concat(chunks);
    const expected = createHmac('sha256', secret)
      .update(`${id}.${timestamp}.`).update(raw).digest();
    if (!timingSafeEqual(expected, Buffer.from(supplied.slice(3), 'hex'))) {
      throw reject(401);
    }
    let event;
    try {
      event = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw));
    } catch {
      throw reject(400);
    }
    if (!event || event.event_id !== id || !subscribed.has(event.type) ||
        typeof event.run_id !== 'string' || typeof event.conversation_id !== 'string') {
      throw reject(400);
    }
    await storeOnce(id, raw);
    console.log(JSON.stringify({ event_id: id, type: event.type, stored: true }));
    response.writeHead(204).end();
  } catch (error) {
    response.writeHead(error.status || 500, { 'Content-Type': 'text/plain' })
      .end('Request rejected');
  }
});
server.requestTimeout = 15_000;
server.headersTimeout = 10_000;
server.maxConnections = 32;
server.listen(8789, '127.0.0.1', () => console.log('Callback receiver: 127.0.0.1:8789'));
```

```bash
node --env-file=callback.env callback-receiver.mjs
```

It listens only on server loopback `127.0.0.1:8789`. Configure a valid public HTTPS reverse proxy **on the same server** from `https://hooks.example.com/onevium/events` to that listener, preserving the body and all three `webhook-*` headers. Do not enter the loopback HTTP address as the Onevium callback URL.

Events contain `event_id`, `type`, `conversation_id`, `generation`, `seq`, `run_id`, `message_id`, and `payload`. A `run.completed` result is in `payload.result`; `run.failed` contains `payload.error`. Your saved business mapping relates the run back to `ticket-42`.

The example hashes event IDs into filenames. Identical duplicates return 204 without another record; changed content under the same ID returns 409. Signature verification precedes JSON parsing. This is example code, not a claim that the receiver or your reverse proxy passed a live test.

## Verify results and delivery records

1. Submit a **new** ticket-version event and retain its admission receipt.
2. Inspect the business conversation and actual run state in Onevium.
3. For subscribed events, open Callback deliveries and inspect state, attempt count, and last error.
4. Match the receiver's stored `event_id/run_id` to this execution. `delivered` proves a 2xx response; verify the business database separately to establish a ticket update.

Without callbacks, use the same App and subject to call `GET /api/v1/runs/:run_id` or subscribe to `/api/v1/runs/:run_id/events`. See [conversations](https://onevium.com/docs/developers/conversations) for SSE recovery. Closing SSE stops observation, not execution.

| Delivery state or response                   | Meaning and action                                                        |
| -------------------------------------------- | ------------------------------------------------------------------------- |
| `pending` / `sending`                        | Waiting or in flight; do not create duplicate tasks                       |
| `delivered`                                  | A 2xx was received; verify business processing separately                 |
| Timeout, connection failure, 408/425/429/5xx | Automatic retry, respecting a valid Retry-After                           |
| Other non-2xx, including redirects           | Requires intervention such as `needs_action`; redirects are not followed  |
| `expired`                                    | Automatic attempt or delivery window ended                                |
| `cancelled_config_changed`                   | Configuration changed; old events are not redirected to a new destination |

Automatic delivery uses a 24-hour window and at most eight attempts. After repairing the receiver, use Retry delivery for `expired` or `needs_action`. Disabled old configurations or expired payload retention cannot be forced through. URL or secret changes create a new configuration version; inspect in-flight old events first.

## Locate a failure

| Symptom                            | First checks                                                                    |
| ---------------------------------- | ------------------------------------------------------------------------------- |
| Incoming 401                       | Correct secret, seconds timestamp/clock, original body, signature format        |
| Incoming 403                       | Enabled Webhook, exact event-type match, application execution access           |
| Incoming 404                       | Full URL and integration ID, not the assistant ID                               |
| Incoming 400 / 413                 | Field paths, value types, valid input, and 1 MiB limit                          |
| Incoming 409                       | Same ID with changed body, or stale control generation                          |
| Run exists without delivery record | Callback enabled state, event subscription, application/device scope            |
| Callback target blocked            | Allowlist, HTTPS/443, DNS addresses, certificate; empty does not mean allow all |
| Receiver 401 / 409                 | Callback secret, original bytes, and deduplication records                      |

## Next steps

Complete one incoming event and result query before adding public callbacks. Review [authentication](https://onevium.com/docs/developers/authentication) and [remote gateway](https://onevium.com/docs/developers/remote-gateway), and record real account, model, TLS, disconnect, and recovery checks separately.
