Skip to content
OneviumDocs
On this page

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 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.

Form fieldExample valueMeaning
Assistant servicePublished test assistantWhich service handles the event
Event type fieldtypeWhere to read the event type in JSON
Event typesticket.updatedExact allowed values; comma-separated
Business subjectsupport-demoFixed identity, not a JSON field path
Event actionContinue conversationThe message action
Message fieldtextText for the assistant
Structured data fielddataA JSON object in this example
Resource IDs fieldEmptyNo attachments in this example
Conversation matchingBusiness conversation keyMatch by a business identifier
Conversation key fieldticket_idContinue the ticket's conversation
EnabledCheckedAccept events

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

ActionAdditional fieldsBehavior
New conversation newMessage/data/resource fieldsEach new event gets a conversation; cannot bind an existing key or ID
Continue message, matched by IDConversation ID and generation fieldsBoth required; generation must be current
Restart resetID, generation, and busy policyReject restart or stop then restart; query the operation for confirmation
Stop run cancelRun ID fieldOnly runs authorized for that service and subject

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)
HeaderValue
webhook-idStable event ID; 1–128 printable ASCII characters without spaces
webhook-timestampUnix timestamp in seconds
webhook-signaturev1, followed by 64 lowercase hexadecimal characters
Content-Typeapplication/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.

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.

Add the callback

Return to Events and callbacks → Add callback:

Form fieldExample
Service on this deviceA test service for the target application/device
Callback URLhttps://hooks.example.com/onevium/events
Subscribed eventsrun.completed, run.failed, approval.required
EnabledChecked

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 for SSE recovery. Closing SSE stops observation, not execution.

Delivery state or responseMeaning and action
pending / sendingWaiting or in flight; do not create duplicate tasks
deliveredA 2xx was received; verify business processing separately
Timeout, connection failure, 408/425/429/5xxAutomatic retry, respecting a valid Retry-After
Other non-2xx, including redirectsRequires intervention such as needs_action; redirects are not followed
expiredAutomatic attempt or delivery window ended
cancelled_config_changedConfiguration 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#

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

Next steps#

Complete one incoming event and result query before adding public callbacks. Review authentication and remote gateway, and record real account, model, TLS, disconnect, and recovery checks separately.