Webhooks

With webhooks, you can trigger http-request to your own backend based on events that occur in Omnidesk. Our implementation of webhooks conforms to the Standard Webhooks specification:

  • Webhook-endpoints consist of an https-URL and a list of subscribed events.

  • A POST-request is send to this URL whenever one of the subscribed events occur within Omnidesk.

  • Failed webhook-requests are retried up to 5 times on an exponential backoff schedule with base 2.

  • Each webhook-request has a signature so that webhook-consumers can verify if it was send by a legitimate source.

Creating Webhook Endpoints

First, you will need to host some HTTP-server that will function as the handler of your. The minimal requirements for a webhook endpoint are as follows:

  1. It MUST be publically accessable (so without authentication) by our backend servers. Also, there MUST be a valid https-URL that points to your endpoint.

  2. It MUST respond to OPTIONS-requests with a 200 or 204 status code. This response also MUST have an Allow-header that contains POST. We use this on our backend to verify if your endpoint is (still) safe use.

  3. It MUST respond to valid POST-requests within 1 second with a 2xx-response (when the webhook-message is accepted) OR a 5xx-response (when some internal error occurs while parsing the message). You should give an early 202-response when you expect that some tasks in the webhook endpoint can take longer than 1 second.

You can create a new webhook endpoint in the Omnidesk UI after you started your webhook consumer:

  • Go to Admin Settings > Webhooks and click on Create.

  • Give the URL of your endpoint.

  • Choose the events to which you endpoint should subscribe.

  • Click on Create.

Our backend will check if your webhook-consumer satisfies (1) and (2) of the minimal requirements when creating a new endpoint. Please check your backend server if you get an error when creating a new webhook-endpoint.

Minimal example (NodeJS)

This example is a minimal implementation of a valid webhook endpoint handler written for NodeJS. The minimal requirements are satisfied by:

  1. Handling OPTIONS-requests that is expected by our backend.

  2. Sending a 202-response to POST-requests as soon as it has received the body. Note that the response is send before it starts any long-running tasks.

import * as http from "node:http";

http.createServer(async function (req, res) {
    // 1. Handle only OPTIONS and POST http-requests.
    if (req.method === "OPTIONS") {
        res.writeHead(204, {
            allow: "OPTIONS, POST",
        }).end();
        return;
    } else if (req.method !== "POST") {
        res.writeHead(405, {
            allow: "OPTIONS, POST",
        }).end();
        return;
    }

    // Retrieve the request contents (also referred to as payload).
    const payload = await streamToString(req);
    // 2. Send a success response.
    res.writeHead(202).end();

    // Parse the message payload.
    const {
        type, // The event type.
        timestamp, // Moment the event occurred (ISO8601 formatted).
        data, // Information about the event itself.
    } = JSON.parse(payload);

    // -- PUT YOUR (Potentially long-running) EVENT HANDLING TASKS HERE --
    console.log(`Handling [${type}] event...`, data);
}).listen(8080);

function streamToString(req) {
    return new Promise((resolve, reject) => {
        const bufs = [];
        req.on("data", (d) => bufs.push(d));
        req.on("end", () => resolve(Buffer.concat(bufs).toString("utf-8")));
        req.on("error", (err) => reject(err));
    });
}

IMPORTANT! Only use this example during development, debugging or for exploring the possibilities of our Websockets feature. It is NOT safe to use this example in production environments, because it does not check if the webhook-request comes from a legitimate source!

Example with signature verification (NodeJS)

Below, you can find a minimal example implementation of a webhook handler written for NodeJS. It uses the asymmetric (ed25519) signature to verify that the incomming webhook-requests were send from our backend.

  1. Handles the OPTIONS request to allow our backend to verify that your webhook-endpoint is safe to use.

  2. Checks if the request has all required headers to verify the incomming webhook-request.

  3. Checks if the webhook-timestamp is close enough to the timestamp of your backend (to prevent replay-attacks).

  4. Verifies that the webhook-signature matches the contents of this of the webhook-request.

import * as crypto from "node:crypto";
import * as http from "node:http";

const PUBLIC_KEY_PREFIX = "whpk_";
const ASYMMETRIC_SIGNATURE_PREFIX = "v1a,";

const TOLERANCE_IN_SECONDS = 5 * 60; // 5 minutes
const PUBLIC_KEY = parseWebhookPublicKey(
    // Set this to the public key of your webhook endpoint!
    "whpk_8akOU8gfL7feQx/gZk1FZJkEwNwIXYOlM6Hjcpc/jBA=",
);

http.createServer(async function (req, res) {
    // 1. Handle the HTTP-method:
    if (req.method === "OPTIONS") {
        res.writeHead(204, {
            allow: "OPTIONS, POST",
        }).end();
        return;
    } else if (req.method !== "POST") {
        res.writeHead(405, {
            allow: "OPTIONS, POST",
        }).end();
        return;
    }

    // 2. Retrieve the headers that are required for validation.
    const msgId = req.headers["webhook-id"];
    const msgTimestamp = req.headers["webhook-timestamp"];
    const msgSignature = req.headers["webhook-signature"];
    if (!msgId || !msgTimestamp || !msgSignature) {
        console.error("Missing required headers");
        res.writeHead(400).end();
        return;
    }

    // 3. Validate the timestamp.
    if (!verifyTimestamp(msgTimestamp)) {
        console.error("Invalid msg timestamp");
        res.writeHead(400).end();
        return;
    }

    // Retrieve the request contents (also referred to as payload).
    const payload = await streamToString(req);

    // 4. Verify the signature.
    if (!verifySignature(msgId, msgTimestamp, payload, msgSignature)) {
        console.error("Invalid msg signature");
        res.writeHead(400).end();
        return;
    }

    // The message is now verified! Send the success response.
    res.writeHead(202).end();

    // Parse the message payload.
    const {
        type, // The event type.
        timestamp, // Moment the event occurred (ISO8601 formatted).
        data, // Information about the event itself.
    } = JSON.parse(payload);

    // -- PUT YOUR EVENT HANDLING TASKS HERE --
    console.log(`Handling [${type}] event...`, data);
}).listen(8080);

function verifyTimestamp(timestampHeader) {
    const now = Math.floor(Date.now() / 1000);
    const timestamp = parseInt(timestampHeader, 10);
    return (
        !Number.isNaN(timestamp) &&
        Math.abs(now - timestamp) <= TOLERANCE_IN_SECONDS
    );
}

function verifySignature(msgId, timestamp, payload, signatureHeader) {
    if (typeof signatureHeader !== "string") return false;

    const signature = signatureHeader
        .split(/\s+/g)
        .find((sig) => sig.startsWith(ASYMMETRIC_SIGNATURE_PREFIX));
    if (!signature) return false;

    const parsedSignature = Buffer.from(
        signature.substring(ASYMMETRIC_SIGNATURE_PREFIX.length),
        "base64",
    );

    const encoder = new TextEncoder();
    console.log(signatureHeader, parsedSignature.length);
    const toVerify = encoder.encode(`${msgId}.${timestamp}.${payload}`);
    return crypto.verify(null, toVerify, PUBLIC_KEY, parsedSignature);
}

function parseWebhookPublicKey(key) {
    if (!key.startsWith(PUBLIC_KEY_PREFIX))
        throw new Error("Invalid public key");
    const rawKey = Buffer.from(
        key.substring(PUBLIC_KEY_PREFIX.length),
        "base64",
    );
    if (rawKey.length !== 32) throw new Error("Invalid public key");
    const ans1Header = Buffer.from([
        0x30, 42,                 // SEQUENCE, len 42
            0x30, 5,                  // SEQUENCE, len 5
                0x06, 0x03, 0x2b, 101, 112 // ALGORITHM OID 1.3.101.112 (Ed25519)
            0x03, 33, 0               // BIT STRING, len 32, 0 unused bits
    ]);
    return crypto.createPublicKey({
        key: Buffer.concat([ans1Header, rawKey]),
        format: "der",
        type: "spki",
    });
}

function streamToString(req) {
    return new Promise((resolve, reject) => {
        const bufs = [];
        req.on("data", (d) => bufs.push(d));
        req.on("end", () => resolve(Buffer.concat(bufs).toString("utf-8")));
        req.on("error", (err) => reject(err));
    });

You can find an example on how to validate webhook requests with a shared secret (HMAC-SHA256) in the standard webhooks example repository.

Last updated