Shortcuts & Webhooks
Use Case: Embeddable widget snippets, webhook receivers for Apple Shortcuts integration, and iframe-based embedding patterns for Notion dashboards. These patterns are extracted from the Notion-Embed repo and real deployments.
Prerequisites
- A web server or serverless function (TanStack Start / Nitro)
- Apple Shortcuts app (iOS/macOS) for shortcut integration
- Notion workspace with embed block support (for widget embeds)
Setup & Configuration
Create a webhook receiver endpoint that accepts POST requests from Apple Shortcuts. The endpoint validates the request, processes the payload, and returns a response.
src/routes/api/webhook.tstypescript
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
const WebhookSchema = z.object({
action: z.enum(["save_bookmark", "run_query", "trigger_scan"]),
payload: z.record(z.unknown()),
timestamp: z.string().datetime(),
signature: z.string().optional(),
});
function verifySignature(
payload: string,
signature: string,
secret: string
): boolean {
const encoder = new TextEncoder();
const key = encoder.encode(secret);
const data = encoder.encode(payload);
// HMAC-SHA256 verification
return crypto.subtle
.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["verify"])
.then((cryptoKey) =>
crypto.subtle.verify(
"HMAC",
cryptoKey,
hexToBytes(signature),
data
)
);
}
export const webhookHandler = createServerFn({ method: "POST" })
.validator((d: unknown) => WebhookSchema.parse(d))
.handler(async ({ data }) => {
// Verify signature if present
if (data.signature) {
const valid = await verifySignature(
JSON.stringify(data.payload),
data.signature,
process.env.WEBHOOK_SECRET!
);
if (!valid) throw new Error("Invalid signature");
}
switch (data.action) {
case "save_bookmark":
return handleSaveBookmark(data.payload);
case "run_query":
return handleRunQuery(data.payload);
case "trigger_scan":
return handleTriggerScan(data.payload);
default:
return { status: "unknown_action" };
}
});Core Implementation
Embeddable widget pattern for Notion (TradingView-style iframe widget):
widget.htmlhtml
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>MCP Dashboard Widget</title>
<style>
body { margin: 0; background: #0f172a; color: #e2e8f0;
font-family: -apple-system, system-ui, sans-serif; }
.widget { padding: 16px; }
.stat { display: flex; justify-content: space-between;
padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
.label { color: #94a3b8; font-size: 13px; }
.value { font-family: "JetBrains Mono", monospace; font-size: 14px; }
</style>
</head>
<body>
<div class="widget" id="widget-root">
<div class="stat">
<span class="label">MCP Tools</span>
<span class="value" id="tool-count">--</span>
</div>
<div class="stat">
<span class="label">Active Sessions</span>
<span class="value" id="session-count">--</span>
</div>
<div class="stat">
<span class="label">Uptime</span>
<span class="value" id="uptime">--</span>
</div>
</div>
<script>
// Auto-resize for Notion iframe embeds
function resize() {
const height = document.documentElement.scrollHeight;
window.parent.postMessage({ height }, "*");
}
window.addEventListener("load", resize);
window.addEventListener("resize", resize);
// Fetch live stats
fetch("/api/stats")
.then(r => r.json())
.then(data => {
document.getElementById("tool-count").textContent = data.tools;
document.getElementById("session-count").textContent = data.sessions;
document.getElementById("uptime").textContent = data.uptime;
resize();
});
</script>
</body>
</html>Dynamic widget with auto-height messaging for cross-origin embeds:
dashboard-widget.htmlhtml
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: -apple-system, system-ui, sans-serif;
margin: 0; background: transparent; }
.ticker { display: flex; gap: 16px; overflow: hidden;
padding: 8px 12px; font-size: 13px; }
.item { white-space: nowrap; }
.price { font-family: "JetBrains Mono", monospace; }
.change { font-size: 12px; }
.change.up { color: #22c55e; }
.change.down { color: #ef4444; }
</style>
</head>
<body>
<div class="ticker" id="ticker"></div>
<script>
// Cross-origin height sync for Notion embeds
function syncHeight() {
const h = document.body.scrollHeight;
window.parent.postMessage({ type: "resize", height: h }, "*");
}
// Stock ticker data (TradingView-style widget pattern)
const symbols = ["AAPL", "GOOGL", "MSFT", "NVDA", "TSLA"];
async function fetchPrices() {
for (const sym of symbols) {
try {
const res = await fetch(`/api/quote?symbol=${sym}`);
const data = await res.json();
const el = document.createElement("div");
el.className = "item";
el.innerHTML = `
<strong>${sym}</strong>
<span class="price">$${data.price.toFixed(2)}</span>
<span class="change ${data.change >= 0 ? "up" : "down"}">
${data.change >= 0 ? "+" : ""}${data.change.toFixed(2)}%
</span>
`;
document.getElementById("ticker").appendChild(el);
} catch {}
}
syncHeight();
}
fetchPrices();
setInterval(fetchPrices, 60000);
</script>
</body>
</html>Apple Shortcuts webhook action for saving bookmarks via MCP:
src/routes/api/shortcuts/save.tstypescript
// Apple Shortcuts webhook target for saving bookmarks via MCP
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
const ShortcutInput = z.object({
title: z.string().min(1),
url: z.string().url(),
notes: z.string().optional(),
});
export const shortcutSaveBookmark = createServerFn({ method: "POST" })
.validator((d: unknown) => ShortcutInput.parse(d))
.handler(async ({ data }) => {
const db = getDb();
await db.execute({
sql: "INSERT INTO bookmarks (title, url, notes, source) VALUES (?, ?, ?, 'shortcut')",
args: [data.title, data.url, data.notes ?? null],
});
// Return plain text so Shortcuts can display it in a notification
return new Response(
`Saved: ${data.title}`,
{
status: 200,
headers: {
"Content-Type": "text/plain",
"X-Source": "mcp-shortcuts-bridge",
},
}
);
});
// In your Apple Shortcut:
// 1. Get contents of URL (POST) to https://your-site.com/api/shortcuts/save
// 2. Headers: Content-Type: application/json
// 3. Request Body: { "data": { "title": "Page Title", "url": "https://..." } }
// 4. Show Notification with "Saved: {title}"Deployment Notes
- CORS: Notion embeds use iframes. Set
Content-Security-Policy: frame-ancestors 'self' https://www.notion.soto allow Notion embedding while preventing clickjacking. - Cross-origin messaging: Use
postMessagefor dynamic height resizing in iframes. Validate the origin on the receiving end. - Shortcuts auth: Generate a shared secret for Shortcuts webhook calls. Pass it as a header or query param. Rotate periodically.
- Rate limits: Apple Shortcuts can fire rapidly. Add a sliding-window rate limiter per IP or per secret.
- Widget caching: For data widgets (price tickers, calendars), add
Cache-Controlheaders matching your update frequency. Notion refreshes embeds on page load. - HTTPS: Notion requires all iframe embeds to use HTTPS. Use your Vercel deployment URL or a custom domain.