diff --git a/apps/backend/src/routes/incoming-webhooks.http.ts b/apps/backend/src/routes/incoming-webhooks.http.ts
index a5c103dee..c0d71ffeb 100644
--- a/apps/backend/src/routes/incoming-webhooks.http.ts
+++ b/apps/backend/src/routes/incoming-webhooks.http.ts
@@ -11,6 +11,7 @@ import {
WebhookNotFoundError,
} from "@hazel/domain/http"
import type { MessageEmbed } from "@hazel/domain/models"
+import { buildMapleEmbed } from "@hazel/integrations/maple"
import { buildOpenStatusEmbed } from "@hazel/integrations/openstatus"
import { buildRailwayEmbed } from "@hazel/integrations/railway"
import { Effect, Option } from "effect"
@@ -344,6 +345,99 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming-
// Update last used timestamp (fire and forget)
yield* webhookRepo.updateLastUsed(webhook.id).pipe(Effect.ignore)
+ return new WebhookMessageResponse({
+ messageId: message.id,
+ channelId: webhook.channelId,
+ })
+ }).pipe(
+ Effect.catchTags({
+ DatabaseError: (error: unknown) =>
+ Effect.fail(
+ new InternalServerError({
+ message: "Database error while creating message",
+ detail: String(error),
+ }),
+ ),
+ SchemaError: (error: unknown) =>
+ Effect.fail(
+ new InternalServerError({
+ message: "Invalid request data",
+ detail: String(error),
+ }),
+ ),
+ }),
+ ),
+ )
+ .handle("executeMaple", ({ params, payload }) =>
+ Effect.gen(function* () {
+ const { webhookId, token } = params
+ const db = yield* Database.Database
+ const webhookRepo = yield* ChannelWebhookRepo
+ const messageRepo = yield* MessageRepo
+ const outboxRepo = yield* MessageOutboxRepo
+ const botService = yield* IntegrationBotService
+
+ const tokenHash = createHash("sha256").update(token).digest("hex")
+
+ const webhookOption = yield* webhookRepo.findById(webhookId)
+
+ if (Option.isNone(webhookOption)) {
+ yield* Effect.logWarning("Webhook not found", { webhookId })
+ return yield* Effect.fail(new WebhookNotFoundError({ message: "Webhook not found" }))
+ }
+
+ const webhook = webhookOption.value
+
+ const tokenBuffer = Buffer.from(tokenHash, "hex")
+ const expectedBuffer = Buffer.from(webhook.tokenHash, "hex")
+ if (
+ tokenBuffer.length !== expectedBuffer.length ||
+ !timingSafeEqual(tokenBuffer, expectedBuffer)
+ ) {
+ yield* Effect.logWarning("Invalid webhook token", { webhookId })
+ return yield* Effect.fail(
+ new InvalidWebhookTokenError({ message: "Invalid webhook token" }),
+ )
+ }
+
+ if (!webhook.isEnabled) {
+ yield* Effect.logWarning("Webhook is disabled", { webhookId: webhook.id })
+ return yield* Effect.fail(new WebhookDisabledError({ message: "Webhook is disabled" }))
+ }
+
+ const botUser = yield* botService.getOrCreateWebhookBotUser("maple", webhook.organizationId)
+
+ const embed = buildMapleEmbed(payload)
+
+ const message = yield* db.transaction(
+ Effect.gen(function* () {
+ const [createdMessage] = yield* messageRepo.insert({
+ channelId: webhook.channelId,
+ authorId: botUser.id,
+ content: "",
+ embeds: [embed],
+ replyToMessageId: null,
+ threadChannelId: null,
+ deletedAt: null,
+ })
+ yield* outboxRepo.insert({
+ eventType: "message_created",
+ aggregateId: createdMessage.id,
+ channelId: createdMessage.channelId,
+ payload: {
+ messageId: createdMessage.id,
+ channelId: createdMessage.channelId,
+ authorId: createdMessage.authorId,
+ content: createdMessage.content,
+ replyToMessageId: createdMessage.replyToMessageId,
+ },
+ })
+ return createdMessage
+ }),
+ )
+
+ yield* webhookRepo.updateLastUsed(webhook.id).pipe(Effect.ignore)
+
return new WebhookMessageResponse({
messageId: message.id,
channelId: webhook.channelId,
diff --git a/apps/web/src/components/channel-settings/maple-section.tsx b/apps/web/src/components/channel-settings/maple-section.tsx
new file mode 100644
index 000000000..77bf229b2
--- /dev/null
+++ b/apps/web/src/components/channel-settings/maple-section.tsx
@@ -0,0 +1,308 @@
+import { useAtomSet } from "@effect/atom-react"
+import type { ChannelId, ChannelWebhookId } from "@hazel/schema"
+import { formatDistanceToNow } from "date-fns"
+import { Exit } from "effect"
+import { useState } from "react"
+import { toast } from "sonner"
+import {
+ createChannelWebhookMutation,
+ deleteChannelWebhookMutation,
+ updateChannelWebhookMutation,
+ type WebhookData,
+} from "~/atoms/channel-webhook-atoms"
+import IconCheck from "~/components/icons/icon-check"
+import IconCopy from "~/components/icons/icon-copy"
+import IconTrash from "~/components/icons/icon-trash"
+import { Badge } from "~/components/ui/badge"
+import { Button } from "~/components/ui/button"
+import { Label } from "~/components/ui/field"
+import { Input } from "~/components/ui/input"
+import { toDate } from "~/lib/utils"
+import { getProviderIconUrl } from "../embeds/use-embed-theme"
+
+const MAPLE_NAME = "Maple"
+
+interface MapleSectionProps {
+ channelId: ChannelId
+ webhook: WebhookData | null
+ onWebhookChange: (operation: "create" | "delete") => void
+ onDone?: () => void
+ variant?: "modal" | "page"
+ onWebhookCreated?: (data: { webhookId: string; token: string }) => void
+}
+
+export function MapleSection({
+ channelId,
+ webhook,
+ onWebhookChange,
+ onDone,
+ variant = "page",
+ onWebhookCreated,
+}: MapleSectionProps) {
+ const mapleLogoUrl = getProviderIconUrl("maple")
+ const [isCreating, setIsCreating] = useState(false)
+ const [showToken, setShowToken] = useState(false)
+ const [createdWebhook, setCreatedWebhook] = useState<{ id: string; token: string } | null>(null)
+ const [copied, setCopied] = useState<"url" | "token" | null>(null)
+ const [isDeleting, setIsDeleting] = useState(false)
+
+ const createWebhook = useAtomSet(createChannelWebhookMutation, { mode: "promiseExit" })
+ const updateWebhook = useAtomSet(updateChannelWebhookMutation, { mode: "promiseExit" })
+ const deleteWebhook = useAtomSet(deleteChannelWebhookMutation, { mode: "promiseExit" })
+
+ const webhookUrl = createdWebhook
+ ? `${import.meta.env.VITE_BACKEND_URL}/webhooks/incoming/${createdWebhook.id}/`
+ : webhook
+ ? `${import.meta.env.VITE_BACKEND_URL}/webhooks/incoming/${webhook.id}/`
+ : null
+ const displayToken = createdWebhook?.token ?? null
+
+ const handleConnect = async () => {
+ setIsCreating(true)
+ const exit = await createWebhook({
+ payload: {
+ channelId,
+ name: MAPLE_NAME,
+ description: "Maple alert notifications",
+ avatarUrl: mapleLogoUrl,
+ integrationProvider: "maple",
+ },
+ })
+
+ Exit.match(exit, {
+ onSuccess: (result) => {
+ toast.success("Maple webhook created")
+ if (onWebhookCreated) {
+ onWebhookCreated({ webhookId: result.data.id, token: result.token })
+ } else {
+ setCreatedWebhook({ id: result.data.id, token: result.token })
+ setShowToken(true)
+ }
+ },
+ onFailure: (cause) => {
+ console.error("Failed to create webhook:", cause)
+ toast.error("Failed to create Maple webhook")
+ },
+ })
+ setIsCreating(false)
+ }
+
+ const handleToggleEnabled = async () => {
+ if (!webhook) return
+ const exit = await updateWebhook({
+ payload: {
+ id: webhook.id as ChannelWebhookId,
+ isEnabled: !webhook.isEnabled,
+ },
+ })
+
+ Exit.match(exit, {
+ onSuccess: () => {
+ toast.success(webhook.isEnabled ? "Maple disabled" : "Maple enabled")
+ onWebhookChange("create")
+ },
+ onFailure: () => {
+ toast.error("Failed to update webhook")
+ },
+ })
+ }
+
+ const handleDelete = async () => {
+ if (!webhook) return
+ setIsDeleting(true)
+ const exit = await deleteWebhook({
+ payload: { id: webhook.id as ChannelWebhookId },
+ })
+
+ Exit.match(exit, {
+ onSuccess: () => {
+ toast.success("Maple webhook deleted")
+ onWebhookChange("delete")
+ },
+ onFailure: () => {
+ toast.error("Failed to delete webhook")
+ },
+ })
+ setIsDeleting(false)
+ }
+
+ const handleCopy = async (value: string, type: "url" | "token") => {
+ const successMessage = type === "url" ? "URL copied" : "Token copied"
+ try {
+ await navigator.clipboard.writeText(value)
+ setCopied(type)
+ toast.success(successMessage)
+ setTimeout(() => setCopied(null), 2000)
+ } catch {
+ toast.error("Failed to copy")
+ }
+ }
+
+ if (showToken && displayToken && webhookUrl) {
+ return (
+
+
+
+
+
+
+ Copy the webhook URL below and paste it as a Hazel destination in Maple → Alerts →
+ Destinations
+
+
+ The full URL includes your secret token. Keep it safe!
+
+
+
+
+
+
+ Webhook URL (paste this in Maple)
+
+
+
+ handleCopy(`${webhookUrl}${displayToken}/maple`, "url")}
+ >
+ {copied === "url" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
{
+ setShowToken(false)
+ setCreatedWebhook(null)
+ onDone?.()
+ }}
+ >
+ Done
+
+
+
+
+ )
+ }
+
+ if (!webhook) {
+ return (
+
+
+
+
+
Connect Maple
+
+ Receive alert triggers and resolves directly in this channel
+
+
+
+
+
+ {isCreating ? "Connecting..." : "Connect Maple"}
+
+
+
+ After connecting, copy the webhook URL and add it as a Hazel destination in Maple → Alerts →
+ Destinations.
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+ Maple
+
+ {webhook.isEnabled ? "Active" : "Disabled"}
+
+
+
Alert notifications
+ {webhook.lastUsedAt && (
+
+ Last alert {formatDistanceToNow(toDate(webhook.lastUsedAt), { addSuffix: true })}
+
+ )}
+
+
+
+
+
+ {webhook.isEnabled ? "Disable" : "Enable"}
+
+
+
+
+
+
+
+
+
Webhook URL
+
+
+ {
+ toast.info("Use 'Regenerate Token' to get a new URL with token")
+ }}
+ >
+
+
+
+
+ Need a new URL?{" "}
+
+ Delete and recreate
+ {" "}
+ the webhook to get a new token.
+
+
+
+ )
+}
diff --git a/apps/web/src/components/embeds/use-embed-theme.ts b/apps/web/src/components/embeds/use-embed-theme.ts
index 2b13dfbd8..0e0630d3a 100644
--- a/apps/web/src/components/embeds/use-embed-theme.ts
+++ b/apps/web/src/components/embeds/use-embed-theme.ts
@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"
import { getBrandfetchIcon } from "~/lib/integrations/__data"
import { resolvedThemeAtom } from "../theme-provider"
-export type EmbedProvider = "linear" | "github" | "figma" | "notion" | "openstatus" | "railway"
+export type EmbedProvider = "linear" | "github" | "figma" | "notion" | "openstatus" | "railway" | "maple"
export interface EmbedTheme {
/** Provider display name */
@@ -54,6 +54,12 @@ export const EMBED_THEMES: Record = {
domain: "railway.com",
logoType: "icon",
},
+ maple: {
+ name: "Maple",
+ color: "#10B981",
+ domain: "maple.dev",
+ logoType: "icon",
+ },
}
/**
diff --git a/apps/web/src/components/integrations/configure-maple-modal.tsx b/apps/web/src/components/integrations/configure-maple-modal.tsx
new file mode 100644
index 000000000..b92178532
--- /dev/null
+++ b/apps/web/src/components/integrations/configure-maple-modal.tsx
@@ -0,0 +1,137 @@
+import type { Channel } from "@hazel/domain/models"
+import type { ChannelId } from "@hazel/schema"
+import { useState } from "react"
+import type { WebhookData } from "~/atoms/channel-webhook-atoms"
+import { MapleSection } from "~/components/channel-settings/maple-section"
+import { getProviderIconUrl } from "~/components/embeds/use-embed-theme"
+import IconHashtag from "~/components/icons/icon-hashtag"
+import { Button } from "~/components/ui/button"
+import { Description } from "~/components/ui/field"
+import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalTitle } from "~/components/ui/modal"
+import { Select, SelectContent, SelectItem, SelectTrigger } from "~/components/ui/select"
+
+type ChannelData = Channel.Type
+
+interface ConfigureMapleModalProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ channels: ChannelData[]
+ selectedChannelId: ChannelId | null
+ existingWebhook: WebhookData | null
+ onSuccess: () => void
+ onWebhookCreated?: (data: { webhookId: string; token: string; channelId: ChannelId }) => void
+}
+
+export function ConfigureMapleModal({
+ isOpen,
+ onOpenChange,
+ channels,
+ selectedChannelId: initialChannelId,
+ existingWebhook,
+ onSuccess,
+ onWebhookCreated,
+}: ConfigureMapleModalProps) {
+ const [selectedChannelId, setSelectedChannelId] = useState(initialChannelId)
+
+ const selectedChannel = channels.find((c) => c.id === selectedChannelId)
+
+ const handleWebhookChange = (operation: "create" | "delete") => {
+ if (operation === "delete") {
+ onSuccess()
+ onOpenChange(false)
+ }
+ }
+
+ const handleDone = () => {
+ onSuccess()
+ onOpenChange(false)
+ }
+
+ const handleClose = () => {
+ onSuccess()
+ onOpenChange(false)
+ }
+
+ const handleWebhookCreated = (data: { webhookId: string; token: string }) => {
+ if (selectedChannelId && onWebhookCreated) {
+ onWebhookCreated({ ...data, channelId: selectedChannelId })
+ }
+ onSuccess()
+ onOpenChange(false)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {existingWebhook ? "Manage Maple" : "Add Maple to Channel"}
+
+
+ {existingWebhook
+ ? `Configure Maple for #${selectedChannel?.name ?? "channel"}`
+ : "Select a channel to receive alert notifications"}
+
+
+
+
+
+
+ {!existingWebhook && (
+
+ Channel
+ setSelectedChannelId(key as ChannelId)}
+ placeholder="Select a channel..."
+ >
+ } />
+
+ {channels.map((channel) => (
+
+ {channel.name}
+
+ ))}
+
+
+
+ )}
+
+ {selectedChannelId && (
+
+ )}
+
+ {!selectedChannelId && !existingWebhook && (
+
+
+
+
+
+ Select a channel above to configure Maple alerts
+
+
+ )}
+
+
+
+
+ {existingWebhook ? "Done" : "Cancel"}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/integrations/maple-integration-content.tsx b/apps/web/src/components/integrations/maple-integration-content.tsx
new file mode 100644
index 000000000..2549a93ff
--- /dev/null
+++ b/apps/web/src/components/integrations/maple-integration-content.tsx
@@ -0,0 +1,303 @@
+import { useAtomSet } from "@effect/atom-react"
+import type { WithVirtualProps } from "@tanstack/db"
+import type { Channel } from "@hazel/domain/models"
+import type { ChannelId, OrganizationId } from "@hazel/schema"
+import { eq, or, useLiveQuery } from "@tanstack/react-db"
+import { formatDistanceToNow } from "date-fns"
+import { useCallback, useMemo, useRef, useState } from "react"
+import { toast } from "sonner"
+import { listOrganizationWebhooksMutation, type WebhookData } from "~/atoms/channel-webhook-atoms"
+import { getProviderIconUrl } from "~/components/embeds/use-embed-theme"
+import IconCheck from "~/components/icons/icon-check"
+import IconCopy from "~/components/icons/icon-copy"
+import IconHashtag from "~/components/icons/icon-hashtag"
+import { Badge } from "~/components/ui/badge"
+import { Button } from "~/components/ui/button"
+import { Input } from "~/components/ui/input"
+import { channelCollection } from "~/db/collections"
+import { useMountEffect } from "~/hooks/use-mount-effect"
+import { exitToast } from "~/lib/toast-exit"
+import { toDate } from "~/lib/utils"
+import { ConfigureMapleModal } from "./configure-maple-modal"
+
+type ChannelData = WithVirtualProps
+
+interface MapleIntegrationContentProps {
+ organizationId: OrganizationId
+}
+
+export function MapleIntegrationContent({ organizationId }: MapleIntegrationContentProps) {
+ const [isModalOpen, setIsModalOpen] = useState(false)
+ const [selectedChannelId, setSelectedChannelId] = useState(null)
+ const [selectedWebhook, setSelectedWebhook] = useState(null)
+ const [webhooks, setWebhooks] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [newlyCreatedWebhook, setNewlyCreatedWebhook] = useState<{
+ webhookId: string
+ token: string
+ channelId: ChannelId
+ } | null>(null)
+ const [copied, setCopied] = useState(false)
+
+ const listWebhooks = useAtomSet(listOrganizationWebhooksMutation, { mode: "promiseExit" })
+
+ const listWebhooksRef = useRef(listWebhooks)
+ listWebhooksRef.current = listWebhooks
+
+ const { data: channelsData } = useLiveQuery(
+ (q) =>
+ q
+ .from({ channel: channelCollection })
+ .where(({ channel }) => eq(channel.organizationId, organizationId))
+ .where(({ channel }) => or(eq(channel.type, "public"), eq(channel.type, "private")))
+ .select(({ channel }) => ({ ...channel })),
+ [organizationId],
+ )
+
+ const channels = channelsData ?? []
+
+ const fetchWebhooks = useCallback(async (isInitial = false) => {
+ if (isInitial) setIsLoading(true)
+ const exit = await listWebhooksRef.current({ payload: {} })
+
+ exitToast(exit)
+ .onSuccess((result) => setWebhooks(result.data))
+ .run()
+ if (isInitial) setIsLoading(false)
+ }, [])
+
+ useMountEffect(() => {
+ void fetchWebhooks(true)
+ })
+
+ const mapleWebhooks = useMemo(() => webhooks.filter((w) => w.name === "Maple"), [webhooks])
+
+ const configuredChannels = useMemo(() => {
+ return mapleWebhooks
+ .map((webhook) => {
+ const channel = channels.find((c) => c.id === webhook.channelId)
+ return { webhook, channel }
+ })
+ .filter(
+ (item): item is { webhook: WebhookData; channel: ChannelData } => item.channel !== undefined,
+ )
+ }, [mapleWebhooks, channels])
+
+ const unconfiguredChannels = useMemo(() => {
+ const configuredIds = new Set(mapleWebhooks.map((w) => w.channelId))
+ return channels.filter((c) => !configuredIds.has(c.id))
+ }, [channels, mapleWebhooks])
+
+ const handleAddChannel = () => {
+ setSelectedChannelId(null)
+ setSelectedWebhook(null)
+ setIsModalOpen(true)
+ }
+
+ const handleManageChannel = (webhook: WebhookData, channel: ChannelData) => {
+ setSelectedChannelId(channel.id)
+ setSelectedWebhook(webhook)
+ setIsModalOpen(true)
+ }
+
+ const handleSuccess = () => {
+ fetchWebhooks()
+ }
+
+ const handleWebhookCreated = (data: { webhookId: string; token: string; channelId: ChannelId }) => {
+ setNewlyCreatedWebhook(data)
+ }
+
+ const handleCopyUrl = async () => {
+ if (!newlyCreatedWebhook) return
+ const url = `${import.meta.env.VITE_BACKEND_URL}/webhooks/incoming/${newlyCreatedWebhook.webhookId}/${newlyCreatedWebhook.token}/maple`
+ try {
+ await navigator.clipboard.writeText(url)
+ setCopied(true)
+ toast.success("URL copied")
+ setTimeout(() => setCopied(false), 2000)
+ } catch {
+ toast.error("Failed to copy")
+ }
+ }
+
+ const handleDismissNewWebhook = () => {
+ setNewlyCreatedWebhook(null)
+ setCopied(false)
+ }
+
+ const isChannelsLoading = mapleWebhooks.length > 0 && channels.length === 0
+ if (isLoading || isChannelsLoading) {
+ return (
+
+
+
+
+
+
+
Loading channels...
+
+
+ )
+ }
+
+ return (
+ <>
+
+
+
Configured Channels
+
+
+
+
+ Add Channel
+
+
+
+ {configuredChannels.length === 0 && !newlyCreatedWebhook ? (
+
+
+
+
+
No channels configured
+
+ Add Maple to a channel to start receiving alert notifications.
+
+
+
+
+
+ Add Your First Channel
+
+
+ ) : (
+
+ {newlyCreatedWebhook && (
+
+
+
+
+
+
+ Copy the webhook URL and add it as a Hazel destination in Maple →
+ Alerts → Destinations
+
+
+ #
+ {channels.find((c) => c.id === newlyCreatedWebhook.channelId)?.name ??
+ "channel"}{" "}
+ • The full URL includes your secret token. Keep it safe!
+
+
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+ Done
+
+
+
+
+ )}
+ {configuredChannels.map(({ webhook, channel }) => (
+
+
+
+
+
+
+
+ {channel.name}
+
+ {webhook.isEnabled ? "Active" : "Disabled"}
+
+
+
+ {webhook.lastUsedAt
+ ? `Last alert ${formatDistanceToNow(toDate(webhook.lastUsedAt), { addSuffix: true })}`
+ : "No alerts received yet"}
+
+
+
+
handleManageChannel(webhook, channel)}
+ >
+ Manage
+
+
+ ))}
+
+ )}
+
+
+
+ >
+ )
+}
diff --git a/apps/web/src/lib/integrations/__data.ts b/apps/web/src/lib/integrations/__data.ts
index a7e3c22ac..90cd5bca5 100644
--- a/apps/web/src/lib/integrations/__data.ts
+++ b/apps/web/src/lib/integrations/__data.ts
@@ -265,6 +265,25 @@ export const integrations: Integration[] = [
],
configOptions: [],
},
+ {
+ id: "maple",
+ name: "Maple",
+ description: "Observability alerts for traces, latency, and error rates.",
+ fullDescription:
+ "Connect Maple to receive alerts directly in your channels when error rates spike, latency degrades, or custom rules trip. Each alert includes severity, observed values, and deep links back to the incident and Maple AI chat.",
+ logoDomain: "maple.dev",
+ logoType: "icon",
+ brandColor: "#10B981",
+ category: "developer-tools",
+ connectionType: "webhook",
+ features: [
+ "Trigger, resolve, and renotify events",
+ "Severity-aware embed coloring",
+ "Deep links to incident & AI chat",
+ "Test deliveries from Maple",
+ ],
+ configOptions: [],
+ },
]
export const categories = [
diff --git a/apps/web/src/routes/_app/$orgSlug/settings/integrations/$integrationId.tsx b/apps/web/src/routes/_app/$orgSlug/settings/integrations/$integrationId.tsx
index 6c8dc0bde..1b6666caf 100644
--- a/apps/web/src/routes/_app/$orgSlug/settings/integrations/$integrationId.tsx
+++ b/apps/web/src/routes/_app/$orgSlug/settings/integrations/$integrationId.tsx
@@ -7,6 +7,7 @@ import { Exit, Option } from "effect"
import { useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { GitHubSubscriptionsSection } from "~/components/integrations/github-subscriptions-section"
+import { MapleIntegrationContent } from "~/components/integrations/maple-integration-content"
import { OpenStatusIntegrationContent } from "~/components/integrations/openstatus-integration-content"
import { RailwayIntegrationContent } from "~/components/integrations/railway-integration-content"
import { RssSubscriptionsSection } from "~/components/integrations/rss-subscriptions-section"
@@ -235,9 +236,12 @@ function IntegrationConfigPage() {
navigate({ to: "/$orgSlug/settings/integrations", params: { orgSlug } })
}
- // OpenStatus, Railway, and RSS use webhook/polling-based integration (per-channel), not OAuth
+ // OpenStatus, Railway, RSS, and Maple use webhook/polling-based integration (per-channel), not OAuth
const isWebhookIntegration =
- integrationId === "openstatus" || integrationId === "railway" || integrationId === "rss"
+ integrationId === "openstatus" ||
+ integrationId === "railway" ||
+ integrationId === "rss" ||
+ integrationId === "maple"
const isApiKeyIntegration = integration?.connectionType === "api-key"
@@ -299,6 +303,8 @@ function IntegrationConfigPage() {
) : integrationId === "rss" ? (
+ ) : integrationId === "maple" ? (
+
) : null)
) : isApiKeyIntegration ? (
<>
diff --git a/packages/domain/src/http/incoming-webhooks.ts b/packages/domain/src/http/incoming-webhooks.ts
index fae136f05..668e1f509 100644
--- a/packages/domain/src/http/incoming-webhooks.ts
+++ b/packages/domain/src/http/incoming-webhooks.ts
@@ -93,6 +93,60 @@ export class RailwayPayload extends Schema.Class("RailwayPayload
timestamp: Schema.String, // ISO 8601 timestamp
}) {}
+// Maple alert webhook schemas (mirror Maple's buildPayload output)
+export const MapleEventType = Schema.Literals(["trigger", "resolve", "renotify", "test"])
+export type MapleEventType = Schema.Schema.Type
+
+export const MapleIncidentStatus = Schema.Literals(["open", "resolved"])
+export type MapleIncidentStatus = Schema.Schema.Type
+
+export const MapleSeverity = Schema.Literals(["warning", "critical"])
+export type MapleSeverity = Schema.Schema.Type
+
+export const MapleComparator = Schema.Literals(["gt", "gte", "lt", "lte"])
+export type MapleComparator = Schema.Schema.Type
+
+export const MapleSignalType = Schema.Literals([
+ "error_rate",
+ "p95_latency",
+ "p99_latency",
+ "apdex",
+ "throughput",
+ "metric",
+ "query",
+])
+export type MapleSignalType = Schema.Schema.Type
+
+export const MapleAlertRule = Schema.Struct({
+ id: Schema.String,
+ name: Schema.String,
+ signalType: MapleSignalType,
+ severity: MapleSeverity,
+ groupKey: Schema.NullOr(Schema.String),
+ comparator: MapleComparator,
+ threshold: Schema.Number,
+ windowMinutes: Schema.Number,
+})
+export type MapleAlertRule = Schema.Schema.Type
+
+export const MapleObserved = Schema.Struct({
+ value: Schema.NullOr(Schema.Number),
+ sampleCount: Schema.NullOr(Schema.Number),
+})
+export type MapleObserved = Schema.Schema.Type
+
+export class MaplePayload extends Schema.Class("MaplePayload")({
+ eventType: MapleEventType,
+ incidentId: Schema.NullOr(Schema.String),
+ incidentStatus: MapleIncidentStatus,
+ dedupeKey: Schema.String,
+ rule: MapleAlertRule,
+ observed: MapleObserved,
+ linkUrl: Schema.String,
+ chatUrl: Schema.String,
+ sentAt: Schema.String,
+}) {}
+
// Response after successful webhook execution
export class WebhookMessageResponse extends Schema.Class("WebhookMessageResponse")({
messageId: Schema.String,
@@ -203,4 +257,29 @@ export class IncomingWebhookGroup extends HttpApiGroup.make("incoming-webhooks")
)
.annotate(RequiredScopes, []),
)
+ .add(
+ HttpApiEndpoint.post("executeMaple", `/:webhookId/:token/maple`, {
+ params: {
+ webhookId: ChannelWebhookId,
+ token: Schema.String,
+ },
+ payload: MaplePayload,
+ success: WebhookMessageResponse,
+ error: [
+ WebhookNotFoundError,
+ WebhookDisabledError,
+ InvalidWebhookTokenError,
+ InternalServerError,
+ ],
+ })
+ .annotateMerge(
+ OpenApi.annotations({
+ title: "Execute Maple Webhook",
+ description:
+ "Receive alert trigger, resolve, renotify, and test events from Maple and post them as rich embeds to a channel.",
+ summary: "Process Maple alert",
+ }),
+ )
+ .annotate(RequiredScopes, []),
+ )
.prefix("/webhooks/incoming") {}
diff --git a/packages/domain/src/rpc/channel-webhooks.ts b/packages/domain/src/rpc/channel-webhooks.ts
index b247c2fbc..80c3e896d 100644
--- a/packages/domain/src/rpc/channel-webhooks.ts
+++ b/packages/domain/src/rpc/channel-webhooks.ts
@@ -81,7 +81,7 @@ export class ChannelWebhookRpcs extends RpcGroup.make(
description: Schema.optional(Schema.String.check(Schema.isMaxLength(500))),
avatarUrl: Schema.optional(AvatarUrl),
/** When set, uses a global integration bot user instead of creating a unique webhook bot */
- integrationProvider: Schema.optional(Schema.Literals(["openstatus", "railway"])),
+ integrationProvider: Schema.optional(Schema.Literals(["openstatus", "railway", "maple"])),
}),
success: ChannelWebhookCreatedResponse,
error: Schema.Union([ChannelNotFoundError, UnauthorizedError, InternalServerError]),
diff --git a/packages/integrations/package.json b/packages/integrations/package.json
index 6fef1b363..ecbb3ff20 100644
--- a/packages/integrations/package.json
+++ b/packages/integrations/package.json
@@ -15,6 +15,8 @@
"./openstatus/browser": "./src/openstatus/browser.ts",
"./railway": "./src/railway/index.ts",
"./railway/browser": "./src/railway/browser.ts",
+ "./maple": "./src/maple/index.ts",
+ "./maple/browser": "./src/maple/browser.ts",
"./craft": "./src/craft/index.ts",
"./discord": "./src/discord/index.ts",
"./rss": "./src/rss/index.ts"
diff --git a/packages/integrations/src/common/bot-configs.ts b/packages/integrations/src/common/bot-configs.ts
index 1b8a41005..8f70b4e57 100644
--- a/packages/integrations/src/common/bot-configs.ts
+++ b/packages/integrations/src/common/bot-configs.ts
@@ -57,7 +57,7 @@ export const INTEGRATION_BOT_CONFIGS: Record
avatarUrl: "https://cdn.brandfetch.io/rss.com/w/64/h/64/theme/dark/icon?token=1id0IQ-4i8Z46-n-DfQ",
botId: "bot-rss",
},
+ maple: {
+ name: "Maple",
+ avatarUrl: "https://cdn.brandfetch.io/maple.dev/w/64/h/64/theme/dark/icon?token=1id0IQ-4i8Z46-n-DfQ",
+ botId: "bot-maple",
+ },
}
/**
diff --git a/packages/integrations/src/maple/browser.ts b/packages/integrations/src/maple/browser.ts
new file mode 100644
index 000000000..c4e34a53c
--- /dev/null
+++ b/packages/integrations/src/maple/browser.ts
@@ -0,0 +1,8 @@
+/**
+ * Browser-safe exports for Maple integration.
+ * This file only exports code that can run in the browser.
+ */
+
+export * from "./colors.ts"
+export * from "./embed-builder.ts"
+export * from "./payloads.ts"
diff --git a/packages/integrations/src/maple/colors.ts b/packages/integrations/src/maple/colors.ts
new file mode 100644
index 000000000..8b8a2957d
--- /dev/null
+++ b/packages/integrations/src/maple/colors.ts
@@ -0,0 +1,49 @@
+import type { BadgeIntent } from "../common/embed-types.ts"
+import type { MapleEventType, MapleSeverity, MapleSignalType } from "./payloads.ts"
+
+const COLOR_CRITICAL = 0xef4444
+const COLOR_WARNING = 0xf59e0b
+const COLOR_RESOLVED = 0x10b981
+const COLOR_NEUTRAL = 0x6366f1
+
+export interface MapleEventStyle {
+ color: number
+ label: string
+ intent: BadgeIntent
+}
+
+/**
+ * Map an event type + severity to a color, badge label, and intent.
+ * Resolves are always green; triggers/renotifies follow severity; tests are neutral.
+ */
+export function getMapleEventStyle(eventType: MapleEventType, severity: MapleSeverity): MapleEventStyle {
+ if (eventType === "resolve") {
+ return { color: COLOR_RESOLVED, label: "Resolved", intent: "success" }
+ }
+ if (eventType === "test") {
+ return { color: COLOR_NEUTRAL, label: "Test", intent: "info" }
+ }
+ const isCritical = severity === "critical"
+ const color = isCritical ? COLOR_CRITICAL : COLOR_WARNING
+ const intent: BadgeIntent = isCritical ? "danger" : "warning"
+ const label = eventType === "renotify" ? "Re-notified" : "Triggered"
+ return { color, label, intent }
+}
+
+const SIGNAL_LABELS: Record = {
+ error_rate: "Error rate",
+ p95_latency: "P95 latency",
+ p99_latency: "P99 latency",
+ apdex: "Apdex",
+ throughput: "Throughput",
+ metric: "Metric",
+ query: "Query",
+}
+
+export function formatSignalType(signal: MapleSignalType): string {
+ return SIGNAL_LABELS[signal] ?? signal
+}
+
+export function severityIntent(severity: MapleSeverity): BadgeIntent {
+ return severity === "critical" ? "danger" : "warning"
+}
diff --git a/packages/integrations/src/maple/embed-builder.ts b/packages/integrations/src/maple/embed-builder.ts
new file mode 100644
index 000000000..56f88d671
--- /dev/null
+++ b/packages/integrations/src/maple/embed-builder.ts
@@ -0,0 +1,111 @@
+import { WEBHOOK_BOT_CONFIGS } from "../common/bot-configs.ts"
+import type { MessageEmbed, MessageEmbedField } from "../common/embed-types.ts"
+import { formatSignalType, getMapleEventStyle, severityIntent } from "./colors.ts"
+import type { MapleAlertPayload, MapleComparator } from "./payloads.ts"
+
+const mapleConfig = WEBHOOK_BOT_CONFIGS.maple
+
+const COMPARATOR_LABEL: Record = {
+ gt: ">",
+ gte: "≥",
+ lt: "<",
+ lte: "≤",
+}
+
+function formatNumber(value: number): string {
+ if (!Number.isFinite(value)) return String(value)
+ if (Math.abs(value) >= 1000 || Number.isInteger(value)) {
+ return value.toLocaleString("en-US", { maximumFractionDigits: 2 })
+ }
+ return value.toFixed(2)
+}
+
+/**
+ * Build embed for a Maple alert webhook event.
+ * Renders trigger / resolve / renotify / test events with severity-aware coloring.
+ */
+export function buildMapleEmbed(payload: MapleAlertPayload): MessageEmbed {
+ const { eventType, rule, observed, linkUrl, sentAt } = payload
+ const style = getMapleEventStyle(eventType, rule.severity)
+ const signalLabel = formatSignalType(rule.signalType)
+
+ const title = rule.groupKey ? `${rule.name} — ${rule.groupKey}` : rule.name
+
+ const description =
+ eventType === "resolve"
+ ? `${signalLabel} returned to healthy range.`
+ : eventType === "test"
+ ? `Test alert for ${signalLabel.toLowerCase()}.`
+ : `${signalLabel} ${COMPARATOR_LABEL[rule.comparator]} ${formatNumber(rule.threshold)} over ${rule.windowMinutes}m.`
+
+ const fields: MessageEmbedField[] = []
+
+ fields.push({
+ name: "Severity",
+ value: rule.severity === "critical" ? "Critical" : "Warning",
+ type: "badge",
+ options: { intent: severityIntent(rule.severity) },
+ inline: true,
+ })
+
+ fields.push({
+ name: "Signal",
+ value: signalLabel,
+ inline: true,
+ })
+
+ if (observed.value !== null) {
+ const observedValue = `${formatNumber(observed.value)} (threshold ${COMPARATOR_LABEL[rule.comparator]} ${formatNumber(rule.threshold)})`
+ fields.push({
+ name: "Observed",
+ value: observedValue,
+ inline: false,
+ })
+ }
+
+ fields.push({
+ name: "Window",
+ value: `${rule.windowMinutes}m`,
+ inline: true,
+ })
+
+ if (rule.groupKey) {
+ fields.push({
+ name: "Group",
+ value: rule.groupKey,
+ inline: true,
+ })
+ }
+
+ if (observed.sampleCount !== null) {
+ fields.push({
+ name: "Samples",
+ value: String(observed.sampleCount),
+ inline: true,
+ })
+ }
+
+ return {
+ title,
+ description,
+ url: linkUrl,
+ color: style.color,
+ author: {
+ name: "Maple",
+ url: "https://maple.dev",
+ iconUrl: mapleConfig.avatarUrl,
+ },
+ footer: {
+ text: "Maple Alerts",
+ iconUrl: mapleConfig.avatarUrl,
+ },
+ image: undefined,
+ thumbnail: undefined,
+ fields: fields.length > 0 ? fields : undefined,
+ timestamp: sentAt,
+ badge: {
+ text: style.label,
+ color: style.color,
+ },
+ }
+}
diff --git a/packages/integrations/src/maple/index.ts b/packages/integrations/src/maple/index.ts
new file mode 100644
index 000000000..1b22af971
--- /dev/null
+++ b/packages/integrations/src/maple/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Maple integration exports.
+ * Full exports including browser-safe and server-side code.
+ */
+
+export * from "./colors.ts"
+export * from "./embed-builder.ts"
+export * from "./payloads.ts"
diff --git a/packages/integrations/src/maple/payloads.ts b/packages/integrations/src/maple/payloads.ts
new file mode 100644
index 000000000..9f1fcf2c0
--- /dev/null
+++ b/packages/integrations/src/maple/payloads.ts
@@ -0,0 +1,45 @@
+/**
+ * Maple alert webhook payload types.
+ * These match Maple's buildPayload() output (apps/api/src/services/AlertsService.ts in maple repo).
+ */
+
+export type MapleEventType = "trigger" | "resolve" | "renotify" | "test"
+export type MapleIncidentStatus = "open" | "resolved"
+export type MapleSeverity = "warning" | "critical"
+export type MapleComparator = "gt" | "gte" | "lt" | "lte"
+export type MapleSignalType =
+ | "error_rate"
+ | "p95_latency"
+ | "p99_latency"
+ | "apdex"
+ | "throughput"
+ | "metric"
+ | "query"
+
+export interface MapleAlertRule {
+ id: string
+ name: string
+ signalType: MapleSignalType
+ severity: MapleSeverity
+ groupKey: string | null
+ comparator: MapleComparator
+ threshold: number
+ windowMinutes: number
+}
+
+export interface MapleObserved {
+ value: number | null
+ sampleCount: number | null
+}
+
+export interface MapleAlertPayload {
+ eventType: MapleEventType
+ incidentId: string | null
+ incidentStatus: MapleIncidentStatus
+ dedupeKey: string
+ rule: MapleAlertRule
+ observed: MapleObserved
+ linkUrl: string
+ chatUrl: string
+ sentAt: string
+}