This guide will walk you through developing a secure and reliable webhook endpoint to interact with Spot AI, focusing on webhook authentication, integrity validation, replay prevention, and testing. By following these guidelines, you’ll ensure seamless and secure communication with Spot’s webhooks.
Introduction to Webhooks
Webhooks are a method of allowing systems to communicate in real-time. Instead of constantly polling for updates, Spot webhooks automatically notify your application when events occur. Spot achieves this by sending HTTP POST requests containing event data directly to your designated webhook endpoint, allowing your application to respond immediately to important events.
How Spot Webhooks Work
- Spot’s Role: Spot generates and sends webhook events to your endpoint. Spot is responsible for handling event delivery, signing messages with a secure RSA256 signature, and managing public keys for message verification.
- Your Role: As a webhook consumer, your role is to:
- Implement an HTTP POST endpoint that can receive Spot webhook messages.
- Verify the authenticity and integrity of received messages using Spot’s public keys.
- Process and handle the event data based on your application’s requirements.
The following sections provide detailed information on securing and verifying Spot webhook messages to ensure data integrity and reliability.
Webhook message anatomy
Each webhook message will be delivered as an HTTP POST request to your designated endpoint.
It will contain the following headers:
Spot-Webhook-Meta- A JSON-string containing the following fields:
exp- expiry of the signature, in epoch-seconds.iat- “issued at”, in epoch-seconds.kid- key id used to sign, use this to identify the corresponding public key from the JWKS to verify the signature.
- A JSON-string containing the following fields:
Spot-Webhook-Signature- The raw RSA256 signature.
The request payload will be a JSON string, it’s shape depends on the type of event that was fired. Here is an example payload for an AI Agents event (formatted here for readability):
{
"event_type": "agent.event",
"timestamp": "2024-10-31T13:01:54.545Z",
"webhook_id": 111,
"data": {
"camera": {
"id": 222,
"name": "Example Camera"
},
"location": {
"id": 333,
"name": "Example Location",
"timezone": "America/New_York"
},
"agent": {
"id": 444,
"name": "Example Agent",
"message": "Custom message, set by user in agent config"
}
}
}Verifying Webhook Messages
Spot webhooks use RSA256 signatures, with a JSON Web Key Set (JWKS) to authenticate and validate messages. Each webhook message includes a digital signature generated with Spot’s private key, and can be verified using a public key accessible through our JWKS URL.
Public JWKS URL: The public keys for verification are hosted at:
https://jwks.spot.ai/webhooks/{org id}/keys.json
Replace {org id} with the id of your organization.
Is this all really required?
Technically, no. You could just take the webhook payload at face value. However, since webhook endpoints are typically publicly-accessible, an attacker could send messages to it. Verifying the signature ensures the message is really from Spot and is something your system should actually act on.
Checking the exp field (from the Spot-Webhook-Meta header) prevents replay attacks, where an attacker could theoretically intercept a valid Spot Webhook message, and resend it at some time in the future. Checking the exp header ensures that the accompanying signature was generated recently, thereby preventing these so-called Replay Attacks.
Why asymmetric signatures?
Asymmetric RSA256, it’s a mouthful, we know. Why didn’t we just use a simple shared secret or an HMAC like many other webhook providers?
Using an asymmetric signing method does require a bit more up-front effort at the code-level, but it frees you from a couple of key responsibilities:
- Secret management. With a shared secret or HMAC signing strategy, you would need to store a shared secret and keep it registered with us. With the asymmetric model, we can fully manage the public and private keys, and there is nothing for you to store. You can regard the public keys as ephemeral, cache them at your discretion, and refetch them on demand.
- Secret rotation. Secrets should be rotated regularly so that, if one were to leak, the exposure time would be limited. In a shared secret model, the burden of rotating the secret falls on you. With the asymmetric model, Spot is able to regularly rotate the keys without you needing to do anything and with zero downtime when it happens.
Using asymmetric signing, Spot is able to fully manage secret storage and rotation without you needing to do anything.
Signing Procedure
- Payload Composition: Each webhook message includes a
Spot-Webhook-Signature, andSpot-Webhook-Metaheaders containing metadata (e.g.,alg,kid,exp). - RSA256 Signature: Spot signs the payload using an RSA private key, ensuring secure, tamper-evident messages.
- Replay Prevention: The
expfield inSpot-Webhook-Metaenforces short-lived tokens to prevent replay attacks.
Signature verification steps
-
Parse the
Spot-Webhook-Metaheader JSON to extractexpandkid. -
Use the public JWKS URL to retrieve and cache Spot’s public keys.
- Select the corresponding key from the JWKS by matching up the
kidfrom step 1.
- Select the corresponding key from the JWKS by matching up the
-
Check that the
exp(expiry) is in the future. Theexpvalue will be in epoch-seconds. -
Construct a signing payload (i.e. canonicalization);
-
Take the original string values of the
Spot-Webhook-Metaheader and the HTTP request body-
Note on JSON formatting
This is relevant when verifying the signature; when constructing the signature payload, the
metaandpayloadvalues must be passed exactly as they appear in the original HTTP request:- no spacing/indentation on the JSON documents
- same property ordering as the original values
One way to be sure you pass correctly-formatted
metaandpayloadare to pass the raw values from the HTTP request. However, some frameworks might automatically parse the body. In that case, make sure to use a JSON.stringify procedure that preserves the original key ordering and allows you to leave out whitespace.
-
-
Encode each to
base64url -
Concatenate them with a dot, e.g:
<encoded-meta>.<encoded-body>
-
-
Use a standard- or third-party JWT library of choice to verify the signature against the formatted payload, using the public key selected in step 2a.
- Specify
RSA-SHA256as the cryptographic signing method.
- Specify
Compatibility with JWT
While we don’t use JWTs directly for Webhooks, the signing procedure has been designed to use the same canonicalization process as JWTs (RFC 7519), allowing you to use popular JWT libraries for verifying the signature. Depending on the library you use, you may not have to perform all of the steps outlined above, as the library might take care of them for you.
Responding to Webhook Messages
What you do with a Webhook Message payload is completely up to you! However, there is one final thing that is relevant: responding to the request to let us know you received it.
Specifically, letting us know whether or not you accepted the message is crucial, since we will retry delivery of messages that fail to be acknowledged.
To acknowledge the successful receipt of a webhook message, respond with any 200-level HTTP status code (e.g. 200 or 204). Any other type of response code will be considered unsuccessful, and Spot will attempt to retry delivery.
Retries happen on an exponential backoff schedule up to a maximum of 5 attempts.
Full Webhook Listener Example (Node.js, Express)
Below is a full example of a webhook listener in TypeScript. This example uses express and jwks-rsa for key management and demonstrates how to verify a Spot webhook message.
Setup and dependencies
Note that, as mentioned before, due to JWT compatibility, in the JS ecosystem you could use a library like node-jose to perform some of the canonicalization logic. However, for illustrative purposes, this example will only use the built-in crypto module.
We do use a library to simplify the process of handling the JWKS. jwks-rsa handles fetching, caching, and retrieving the correct key by kid.
This example uses ngrok to expose your locally running webhook on a public endpoint. It assumes you have the following environment variables set up:
NGROK_AUTHTOKEN- your ngrok authtokenNGROK_DOMAIN- your ngrok domain
Code Example
import crypto from "crypto";
import ngrok from "@ngrok/ngrok";
import express from "express";
import jwksClient from "jwks-rsa";
const client = jwksClient({
jwksUri: "https://jwks.spot.ai/webhooks/{org id}/keys.json",
// timeout: 30000, // Defaults to 30s
});
/**
* An example webhook listener to showcase how to receive Spot Webhook messages.
*/
const app = express();
app.use(express.json());
app.post("/webhook", async (req, res) => {
const meta = req.headers["spot-webhook-meta"] as string;
const payload = req.body;
const signature = req.headers["spot-webhook-signature"] as string;
if (!signature || !meta) {
console.log("Missing signature or headers");
res.sendStatus(400);
return;
}
console.log("Received webhook message", { meta, payload, signature });
const verified = await verifySignature(
meta,
JSON.stringify(payload),
signature
);
if (!verified) {
console.log("Invalid signature");
res.sendStatus(400);
return;
}
// express bodyparser greedily parses json due to the content-type header
const parsedBody = payload;
// doStuffWith(parsedBody)
console.log(parsedBody);
res.sendStatus(200);
});
const port = process.env.PORT || 9000;
app.listen(port, async () => {
console.log(`Webhook listener started on port ${port}`);
// To use ngrok like this, we recommend going through the following steps:
// 1. Sign up for ngrok: https://ngrok.com/
// 2. Grab your authtoken from the dashboard: https://dashboard.ngrok.com/get-started/your-authtoken
// 3. Set the authtoken in your .env file: NGROK_AUTHTOKEN=your-authtoken
// 4. Set up a free static domain in your ngrok account
// 5. Set the domain in your .env file: NGROK_DOMAIN=your-domain
const listener = await ngrok.forward({
port: Number(port),
authtoken_from_env: true,
hostname: process.env.NGROK_DOMAIN,
});
console.log(`Ngrok started on on ${listener.url()}/webhook`);
});
async function verifySignature(
rawMeta: string,
rawPayload: string,
signature: string
) {
const verifier = crypto.createVerify("RSA-SHA256");
// Canonicalization (applies to both payload and meta):
// 1. start from JSON string representation
// a. WITHOUT indentation (e.g. JSON.stringify(parsedPayload))
// b. Keys must be in the same order as original payload
// 2. base64url-encode the result
// 3. join the two base64url-encoded strings [meta, payload] with a period
verifier.update(
[rawMeta, rawPayload]
.map((x) => Buffer.from(x, "utf8").toString("base64url"))
.join(".")
);
const parsedHeader = JSON.parse(rawMeta);
if (!verifyExpiry(parsedHeader.exp)) {
console.log("Expired webhook message");
return false;
}
const key = await client.getSigningKey(parsedHeader.kid);
return verifier.verify(key.getPublicKey(), signature, "base64");
}
/**
* Verify that a webhook message hasn't expired yet.
* @param expMeta number - directly from the meta header; epoch seconds
*/
function verifyExpiry(expMeta: number) {
return expMeta > Date.now() / 1000;
}Explanation of code example
Incorporating all of the instructions;
- Extract and Parse Headers:
Spot-SignatureSpot-Webhook-Meta
- Canonicalize and Encode Payload:
- Encode
Spot-Webhook-Metaand the JSON payload asbase64urlstrings. - Concatenate them with a period (
.).
- Encode
- Fetch Public Key:
- Use the
kidfromSpot-Webhook-Metato locate the corresponding key in Spot’s JWKS.
- Use the
- Verify Signature:
- Use the RSA256 algorithm with the public key to verify the authenticity of the payload.
- Verify Expiry:
- Ensure the
exptimestamp inSpot-Webhook-Metais valid to prevent replay attacks. Note thatexpandiatmeta fields are in epoch-seconds.
- Ensure the
