Receive and verify webhooks
Register a webhook, verify the HMAC signature on delivery, and fetch the summary that triggered it.
Instead of polling for a summary to finish, register a webhook. Meetily pings your server when the event fires, your server verifies the delivery is genuine, then fetches the actual summary over the API.
The webhook payload itself is thin - it just tells you which event happened and which meeting it's about. You still call the API to get the content.
Register the webhook
curl -s -X POST http://127.0.0.1:8420/v1/webhooks \
-H "Authorization: Bearer $MEETILY_PRO_TOKEN" \
-H "content-type: application/json" \
-d '{
"url": "https://your-public-url/hook",
"events": ["summary.completed"]
}'The response includes the signing secret. It's returned once, at creation time. Store it somewhere durable - you can't fetch it again later, and you'll need it to verify deliveries.
The target URL must be public
The gateway rejects 127.0.0.1 and other private/loopback addresses with 400 by default - this is SSRF protection, not a bug. https://your-public-url/hook above is a placeholder for a real public endpoint. To receive webhooks on your laptop, run a public tunnel (ngrok, Cloudflare Tunnel, or similar) and register the tunnel's https:// URL, not localhost.
Verify the signature
Every delivery carries two headers:
X-Meetily-Signature, formatted assha256=<hex>X-Meetily-Timestamp, a Unix timestamp in seconds
The expected signature is an HMAC-SHA256 over the string timestamp + "." + body, keyed with your secret, rendered as lowercase hex. body is the raw request bytes exactly as received - don't re-serialize JSON before hashing, since re-serialization can reorder keys or change whitespace and break the signature.
Compare the computed signature to the header using a constant-time comparison, not == or .equals(). A timing-sensitive comparison can leak the correct signature one byte at a time.
Receiver
Each example below starts a small HTTP server, reads the raw body, computes the expected signature, and rejects the request with 401 if it doesn't match. The secret comes from an environment variable, never hardcoded.
Standard library only - http.server, hmac, hashlib.
import hmac
import hashlib
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["MEETILY_WEBHOOK_SECRET"].encode()
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
timestamp = self.headers.get("X-Meetily-Timestamp", "")
signature = self.headers.get("X-Meetily-Signature", "")
signed_payload = timestamp.encode() + b"." + body
expected = "sha256=" + hmac.new(SECRET, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
self.send_response(401)
self.end_headers()
return
print("verified event:", body.decode())
self.send_response(200)
self.end_headers()
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()Built-in http and crypto, no dependencies. crypto.timingSafeEqual requires both buffers to be the same length, so check that first.
const http = require("http");
const crypto = require("crypto");
const SECRET = process.env.MEETILY_WEBHOOK_SECRET;
http.createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const body = Buffer.concat(chunks);
const timestamp = req.headers["x-meetily-timestamp"] || "";
const signature = req.headers["x-meetily-signature"] || "";
const signedPayload = Buffer.concat([Buffer.from(timestamp), Buffer.from("."), body]);
const expected =
"sha256=" + crypto.createHmac("sha256", SECRET).update(signedPayload).digest("hex");
const expectedBuf = Buffer.from(expected);
const signatureBuf = Buffer.from(signature);
const valid =
expectedBuf.length === signatureBuf.length &&
crypto.timingSafeEqual(expectedBuf, signatureBuf);
if (!valid) {
res.writeHead(401);
res.end();
return;
}
console.log("verified event:", body.toString());
res.writeHead(200);
res.end();
});
}).listen(8000);Uses tiny_http for the server, hmac and sha2 for the signature, and hex to encode it. Add these to Cargo.toml:
[dependencies]
tiny_http = "0.12"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
subtle = "2"use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::env;
use subtle::ConstantTimeEq;
use tiny_http::{Response, Server};
type HmacSha256 = Hmac<Sha256>;
fn main() {
let secret = env::var("MEETILY_WEBHOOK_SECRET").expect("MEETILY_WEBHOOK_SECRET not set");
let server = Server::http("0.0.0.0:8000").unwrap();
for mut request in server.incoming_requests() {
let mut body = Vec::new();
request.as_reader().read_to_end(&mut body).unwrap();
let timestamp = header_value(&request, "X-Meetily-Timestamp").unwrap_or_default();
let signature = header_value(&request, "X-Meetily-Signature").unwrap_or_default();
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(&body);
let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
let valid = expected.as_bytes().ct_eq(signature.as_bytes()).into();
if !valid {
request.respond(Response::from_string("unauthorized").with_status_code(401)).unwrap();
continue;
}
println!("verified event: {}", String::from_utf8_lossy(&body));
request.respond(Response::from_string("ok")).unwrap();
}
}
fn header_value(request: &tiny_http::Request, name: &str) -> Option<String> {
request
.headers()
.iter()
.find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(name))
.map(|h| h.value.as_str().to_string())
}Fetch the summary
Once the signature checks out, parse the event body to get the meeting id, then fetch the summary content over the API. The webhook itself never carries the summary text.
curl -s "http://127.0.0.1:8420/v1/meetings/MEETING_ID/summary" -H "Authorization: Bearer $MEETILY_PRO_TOKEN"From there, do whatever you need with it - post it to Slack, write it to a database, kick off another job. The webhook's only job was to tell you it was time to look.
Last updated on
Drive a recording from a script
Start a recording, sleep through the meeting, stop it, then list meetings and export the newest summary, all from a script that shells out to the meetily-pro CLI.
Drive Meetily from your own MCP client
Speak JSON-RPC directly to the meetily-pro mcp server over stdio, without an AI client in between.
