Horizon

Test Cisco Webex webhooks locally

Receive Cisco Webex webhook events on localhost with a Horizon tunnel, create the webhook through the API, and verify X-Spark-Signature.

Receive Webex events on your laptop while you build, with a URL Webex can reach.

Webex has no dashboard page for webhooks. You create them with the Webex API.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A reserved subdomain for -s. Reserve one on the Subdomains page.
  • A Webex access token. To receive messages, the token needs the spark:messages_read scope.

Start your app

When you give a webhook a secret, Webex sends an HMAC of the raw JSON body in the X-Spark-Signature header. The algorithm is HMAC SHA1, and the digest is hex. Read the body with request.text() before you parse it.

app/api/webhooks/webex/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  const secret = process.env.WEBEX_WEBHOOK_SECRET;
  if (!secret) {
    return new Response("Missing WEBEX_WEBHOOK_SECRET", { status: 500 });
  }

  const body = await request.text();
  const received = request.headers.get("x-spark-signature") ?? "";
  const expected = createHmac("sha1", secret).update(body).digest("hex");

  const receivedBuffer = Buffer.from(received);
  const expectedBuffer = Buffer.from(expected);
  const isValid =
    receivedBuffer.length === expectedBuffer.length &&
    timingSafeEqual(receivedBuffer, expectedBuffer);

  if (!isValid) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(body);
  console.log(`Received Webex event: ${event.resource} ${event.event}`);

  return new Response("ok", { status: 200 });
}

Pick a random secret and store it in an environment variable.

.env.local
WEBEX_WEBHOOK_SECRET=replace-with-a-long-random-string

Start the app on port 3000.

npm run dev

Start a tunnel

Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so your webhook would point at a dead URL after a restart. Reserve it first on the Subdomains page. Reserved subdomains are a paid feature, see Pricing.

hrzn tunnel http://localhost:3000 -s my-webex

Horizon prints HORIZON: Tunnel connected. Your public URL is https://my-webex.hrzn.run. Keep this terminal open.

Create the webhook with the API

Send POST https://webexapis.com/v1/webhooks with your token as a Bearer token. name, targetUrl, resource and event are required. secret is optional, but the handler above needs it. filter narrows the events, for example to one room.

curl -X POST https://webexapis.com/v1/webhooks \
  -H "Authorization: Bearer $WEBEX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Horizon test",
    "targetUrl": "https://my-webex.hrzn.run/api/webhooks/webex",
    "resource": "messages",
    "event": "created",
    "secret": "replace-with-a-long-random-string"
  }'

Use the same value for secret as for WEBEX_WEBHOOK_SECRET.

Trigger an event

Webex's docs describe no resend for webhook deliveries. Trigger a real event instead. With the webhook above, post a message in a space the token's owner is in.

Check it works

Post a message. The Horizon terminal prints one line:

Output
  POST    200  /api/webhooks/webex

Your app terminal prints:

Output
Received Webex event: messages created

Troubleshooting

The signature doesn't match

  • Check that WEBEX_WEBHOOK_SECRET is identical to the secret you sent to the API.
  • Compute the HMAC over the raw body. Don't run JSON.parse and JSON.stringify first.
  • Use SHA1 with a hex digest.
  • Restart npm run dev after you edit .env.local.

The webhook has no signature header

You created the webhook without secret. Create it again with a secret.

The API rejects the request

The token needs a read scope for the resource. For messages, that is spark:messages_read.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-webex and create the webhook again with the new targetUrl. -s needs a subdomain you reserved, see Pricing.

Next steps

On this page