snippets/shortcuts-webhooks

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.so to allow Notion embedding while preventing clickjacking.
  • Cross-origin messaging: Use postMessage for 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-Control headers 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.