import React, { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Webhook, Plus, Trash2, Pencil, Send, Copy, Check, Loader2, ExternalLink, RefreshCw, AlertTriangle, CheckCircle2, XCircle, Eye, RotateCcw, ShieldCheck, Crown, Clock, Zap, ChevronDown, Filter, } from "lucide-react"; import { __, sprintf } from "../lib/i18n"; import { PageHeader } from "../components/common/PageHeader"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../components/ui/card"; import { Alert } from "../components/ui/alert"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Select } from "../components/ui/select"; import { Badge } from "../components/ui/badge"; import { Modal } from "../components/ui/modal"; import { ModulePageSkeleton } from "../components/ui/module-skeleton"; import { Switch } from "../components/ui/switch"; import { Tooltip } from "../components/ui/tooltip"; import { ConfirmationDialog } from "../components/ui/confirmation-dialog"; import { Pagination } from "../components/shared/Pagination"; import { useToast } from "../components/ui/toast"; import { Table as SharedTable } from "../components/shared/Table"; import { webhooksApi, type ListenStatus, type WebhookDeliveryRow, type WebhookEndpoint, type WebhookEndpointWriteInput, type WebhookEvent, type WebhooksMeta, } from "../api/webhooks-api"; /** * Yatra → Webhooks — Agency-tier outbound integration hub. * * Two tabs: * 1. Endpoints — CRUD against `wp_yatra_webhook_endpoints` * 2. Deliveries — paginated audit log with full payload inspection * (the "listen mode" view) * * Critical UX patterns: * - The signing secret is shown ONCE on create + regenerate, in a * copy-only modal. Subsequent reads only see the last-4 hint. * Mirrors Stripe / GitHub / WP REST best practice. * - HTTPS-only is enforced server-side (WP_DEBUG allows HTTP for * local dev). The form labels this clearly. * - Auto-disabled endpoints (after 10 permanent failures) show a * red banner with a re-enable affordance. * * White-label-safe: no product names in user-facing copy; textdomain * `yatra` for translation parity with the rest of the admin React. */ /* -------------------------------------------------------------------------- */ /* Shared key/value editor — used for both custom HTTP headers AND the */ /* additional payload fields editor on the endpoint form. */ /* -------------------------------------------------------------------------- */ interface KvRow { key: string; value: string; } /** Object → list of rows. Stable order = insertion order (Object.entries). */ function objectToRows(obj: Record): KvRow[] { return Object.entries(obj || {}).map(([k, v]) => ({ key: k, value: v })); } /** Rows → object. Blank rows skipped; later rows clobber earlier ones with * the same key (server-side validation rejects truly invalid keys). */ function rowsToObject(rows: KvRow[]): Record { const out: Record = {}; for (const r of rows) { const k = r.key.trim(); const v = r.value; if (k === "") continue; out[k] = v; } return out; } const KeyValueEditor: React.FC<{ rows: KvRow[]; onChange: (rows: KvRow[]) => void; keyPlaceholder: string; valuePlaceholder: string; /** Used to namespace input id attributes for a11y. */ idPrefix: string; }> = ({ rows, onChange, keyPlaceholder, valuePlaceholder, idPrefix }) => { const setRow = (idx: number, patch: Partial) => { const next = rows.slice(); next[idx] = { ...next[idx], ...patch }; onChange(next); }; const removeRow = (idx: number) => { onChange(rows.filter((_, i) => i !== idx)); }; const addRow = () => onChange([...rows, { key: "", value: "" }]); return (
{rows.length === 0 ? (

{__("None configured.", "yatra")}

) : ( rows.map((row, idx) => (
setRow(idx, { key: e.target.value })} placeholder={keyPlaceholder} className="font-mono text-sm flex-1" /> setRow(idx, { value: e.target.value })} placeholder={valuePlaceholder} className="font-mono text-sm flex-[2]" />
)) )}
); }; type WebhookTab = "endpoints" | "deliveries" | "buried"; function getInitialTab(): WebhookTab { if (typeof window === "undefined") return "endpoints"; const tab = new URLSearchParams(window.location.search).get("tab"); if (tab === "deliveries" || tab === "logs") return "deliveries"; if (tab === "buried" || tab === "dead-letter" || tab === "dlq") return "buried"; return "endpoints"; } const Webhooks: React.FC = () => { const [activeTab, setActiveTab] = useState(() => getInitialTab()); const switchTab = (next: WebhookTab) => { setActiveTab(next); if (typeof window !== "undefined") { const url = new URL(window.location.href); url.searchParams.set("tab", next); window.history.replaceState({}, "", url.toString()); } }; const { data: meta, isLoading: metaLoading } = useQuery({ queryKey: ["webhooks-meta"], queryFn: () => webhooksApi.getMeta(), }); if (metaLoading) { return ; } if (!meta) return null; // Hard tier gate — Agency-only. if (!meta.is_agency_active) { return (
); } if (!meta.is_module_enabled) { return (
); } const tabs: Array<{ key: WebhookTab; label: string; icon: any }> = [ { key: "endpoints", label: __("Endpoints", "yatra"), icon: Webhook }, { key: "deliveries", label: __("Deliveries", "yatra"), icon: Clock }, // "Buried" surfaces every delivery that exhausted retries + // landed in permanent_failure. Bulk-replay lives here so operators // triage from the summary instead of paging through deliveries. { key: "buried", label: __("Buried", "yatra"), icon: AlertTriangle }, ]; return (
{activeTab === "endpoints" && } {activeTab === "deliveries" && } {activeTab === "buried" && }
); }; /* -------------------------------------------------------------------------- */ /* Upgrade / module-disabled cards */ /* -------------------------------------------------------------------------- */ const UpgradeCard: React.FC<{ meta: WebhooksMeta }> = ({ meta }) => (
{__("Webhooks", "yatra")} {__( "Available on the Scale plan. The integration backbone for agencies wiring Yatra into a custom tech stack — sync bookings to CRMs, post revenue events to accounting tools, trigger Zapier workflows, ping Slack on VIP bookings.", "yatra", )}
{[ [ __("Encrypted per-endpoint secrets", "yatra"), __("Libsodium-backed at-rest encryption.", "yatra"), ], [ __("HMAC-SHA256 signed payloads", "yatra"), __("Stripe-style signature header.", "yatra"), ], [ __("Automatic retry with backoff", "yatra"), __("5 attempts with jittered exponential delay.", "yatra"), ], [ __("Full delivery log + replay", "yatra"), __("Every payload preserved for 90 days.", "yatra"), ], ].map(([title, sub]) => (
{title}
{sub}
))}
); const ModulePrompt: React.FC = () => ( {__("Enable the Webhooks module", "yatra")} {__( 'Toggle "Webhooks" on under Yatra → Modules to start configuring endpoints.', "yatra", )} ); /* -------------------------------------------------------------------------- */ /* Endpoints tab */ /* -------------------------------------------------------------------------- */ const EndpointsTab: React.FC = () => { const queryClient = useQueryClient(); const { showToast } = useToast(); const [editing, setEditing] = useState(null); const [pendingDelete, setPendingDelete] = useState( null, ); // The plaintext secret only ever lives in memory — we show it once // in a copy-only modal then drop it. Stripe-style. const [revealSecret, setRevealSecret] = useState<{ secret: string; endpointName: string; } | null>(null); // After a Ping, open the delivery inspector immediately — operators // shouldn't have to tab-switch to find out whether the test worked. const [pingInspectId, setPingInspectId] = useState(null); // mTLS client-cert dialog state. Holds the endpoint we're editing // the cert for; null = dialog closed. const [mtlsEndpoint, setMtlsEndpoint] = useState( null, ); const { data: endpointsData, isLoading } = useQuery({ queryKey: ["webhooks-endpoints"], queryFn: () => webhooksApi.listEndpoints(), }); const { data: eventsData } = useQuery({ queryKey: ["webhooks-events"], queryFn: () => webhooksApi.listEvents(), }); const deleteEndpoint = useMutation({ mutationFn: (id: number) => webhooksApi.deleteEndpoint(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["webhooks-endpoints"] }); showToast(__("Endpoint deleted.", "yatra"), "success"); setPendingDelete(null); }, onError: (e: any) => { showToast(extractError(e), "error"); setPendingDelete(null); }, }); const pingEndpoint = useMutation({ mutationFn: (id: number) => webhooksApi.pingEndpoint(id), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["webhooks-deliveries"] }); // Pop the inspector for the new row so the operator sees the // dispatch progress in real time. The inspector itself polls // until the row reaches a terminal status. setPingInspectId(res.row_id); }, onError: (e: any) => showToast(extractError(e), "error"), }); const regenerateSecret = useMutation({ mutationFn: (id: number) => webhooksApi.regenerateSecret(id), onSuccess: (res, id) => { queryClient.invalidateQueries({ queryKey: ["webhooks-endpoints"] }); const endpoint = endpointsData?.data.find((e) => e.id === id); setRevealSecret({ secret: res.secret, endpointName: endpoint?.name || __("this endpoint", "yatra"), }); }, onError: (e: any) => showToast(extractError(e), "error"), }); if (editing !== null) { return ( setEditing(null)} onCreated={(secret, name) => { setEditing(null); setRevealSecret({ secret, endpointName: name }); }} /> ); } const endpoints = endpointsData?.data ?? []; return (

{__("HTTP endpoints", "yatra")}

{__( "Each endpoint receives a signed POST when one of its subscribed events fires. The signing secret is shown once on create — store it in your receiver's config.", "yatra", )}

{/* Canonical Yatra table — same shell as Activities / Destinations. * Name is a clickable link that opens the edit view; secondary * actions live in the per-row 3-dot menu. SharedTable handles * loading / empty / error states for us. */} (
{ep.url}
{ep.consecutive_failures >= 5 && (
{ep.consecutive_failures}{" "} {__("consecutive failures", "yatra")}
)}
), }, { key: "status", label: __("Status", "yatra"), render: (ep: WebhookEndpoint) => ep.is_active ? ( {__("Active", "yatra")} ) : ( {__("Inactive", "yatra")} ), }, { key: "event", label: __("Event", "yatra"), render: (ep: WebhookEndpoint) => ( <> {ep.event ? ( {ep.event} ) : ( )} {ep.selected_fields && ep.selected_fields.length > 0 && (
{ep.selected_fields.length} {__("fields", "yatra")}
)} {!ep.log_deliveries && (
{__("Logging off", "yatra")}
)} ), }, { key: "health", label: __("Health", "yatra"), render: (ep: WebhookEndpoint) => ( ), }, { key: "last_delivered_at", label: __("Last delivery", "yatra"), render: (ep: WebhookEndpoint) => ep.last_delivered_at ? (
{new Date(ep.last_delivered_at).toLocaleString()}
{ep.last_status || "—"}
) : ( ), }, ]} actions={[ { key: "edit", label: __("Edit", "yatra"), icon: , onClick: (ep: WebhookEndpoint) => setEditing(ep), }, { key: "ping", label: __("Send test ping", "yatra"), icon: , onClick: (ep: WebhookEndpoint) => pingEndpoint.mutate(ep.id), condition: (ep: WebhookEndpoint) => ep.is_active, }, { key: "regenerate", label: __("Regenerate signing secret", "yatra"), icon: , onClick: (ep: WebhookEndpoint) => regenerateSecret.mutate(ep.id), }, { key: "mtls", label: __("Client certificate (mTLS)", "yatra"), icon: , onClick: (ep: WebhookEndpoint) => setMtlsEndpoint(ep), }, { key: "delete", label: __("Delete endpoint", "yatra"), icon: , onClick: (ep: WebhookEndpoint) => setPendingDelete(ep), variant: "destructive", }, ]} isLoading={isLoading} emptyText={__("No endpoints configured yet", "yatra")} emptyDescription={__( "Add your first endpoint to start receiving events. Common targets: Zapier catch hooks, Make scenarios, internal CRM ingest URLs, Slack webhook URLs.", "yatra", )} onCreateClick={() => setEditing("new")} />
{ if (!deleteEndpoint.isPending) setPendingDelete(null); }} onConfirm={() => { if (pendingDelete) deleteEndpoint.mutate(pendingDelete.id); }} title={__("Delete endpoint?", "yatra")} description={ pendingDelete ? __( 'Delete "{name}"? Its delivery log will also be removed. This cannot be undone.', "yatra", ).replace("{name}", pendingDelete.name) : "" } confirmText={__("Delete endpoint", "yatra")} cancelText={__("Cancel", "yatra")} variant="danger" isLoading={deleteEndpoint.isPending} /> setRevealSecret(null)} /> {/* Live ping inspector — polls until the row reaches a terminal * status so the operator sees the test result without leaving * the Endpoints tab. */} {pingInspectId !== null && ( setPingInspectId(null)} /> )} setMtlsEndpoint(null)} />
); }; /* -------------------------------------------------------------------------- */ /* Client-side mirror of PayloadFilter::apply() — used by the "Output */ /* preview" panel so operators can see EXACTLY what their receiver will get */ /* before saving. Behavior MUST stay in lockstep with the PHP service: */ /* - envelope keys (id/type/api_version/occurred_at) are always preserved */ /* - data block is filtered to selected dot-paths */ /* - picking a parent path ships the entire subtree */ /* - empty selection OR ["*"] = send everything */ /* -------------------------------------------------------------------------- */ const ENVELOPE_KEYS = ["id", "type", "api_version", "occurred_at"] as const; function applyFieldFilter( payload: Record | null, selected: string[], ): Record | null { if (payload === null) return null; // No filter / wildcard → return as-is. if (selected.length === 0 || selected.includes("*")) return payload; // Normalize paths: strip leading "data." so paths are always // relative to the data block, same as the server. const paths = Array.from( new Set( selected .map((p) => p.trim()) .filter((p) => p !== "") .map((p) => (p.startsWith("data.") ? p.slice(5) : p)) .filter((p) => p !== ""), ), ); const out: Record = {}; // Envelope always copies through. for (const k of ENVELOPE_KEYS) { if (k in payload) out[k] = payload[k]; } const data = payload.data; if (data === null || typeof data !== "object" || Array.isArray(data)) { out.data = data; return out; } const filteredData: Record = {}; for (const path of paths) { extractInto( data as Record, path.split("."), 0, filteredData, ); } out.data = filteredData; return out; } function extractInto( tree: Record, parts: string[], depth: number, out: Record, ): void { if (depth >= parts.length) return; const key = parts[depth]; if (!(key in tree)) return; const isLeaf = depth === parts.length - 1; if (isLeaf) { out[key] = tree[key]; return; } const child = tree[key]; if (typeof child !== "object" || child === null || Array.isArray(child)) { // Path goes deeper but tree has a scalar/array here — bail out. return; } let nested = out[key]; if (typeof nested !== "object" || nested === null || Array.isArray(nested)) { nested = {}; out[key] = nested; } extractInto( child as Record, parts, depth + 1, nested as Record, ); } /* -------------------------------------------------------------------------- */ /* Endpoint health snapshot — colored success-rate badge + delivered/failed */ /* ratio. Computed server-side over the last 100 terminal deliveries so it */ /* reflects current behaviour, not lifetime stats. */ /* -------------------------------------------------------------------------- */ const HealthCell: React.FC<{ health: WebhookEndpoint["health"]; }> = ({ health }) => { if (!health || health.total === 0) { return ( {__("No data", "yatra")} ); } const rate = health.success_rate ?? 0; const cls = rate >= 95 ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300" : rate >= 80 ? "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300" : "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"; return (
{rate}%
{health.delivered}/{health.total}
); }; /* -------------------------------------------------------------------------- */ /* Field selector — Pabbly-style checklist of dot-paths inside the event's */ /* payload, sourced from the sample payload endpoint. Empty selection + */ /* "Send all" toggle = send the whole data block; otherwise only the picked */ /* paths flow through (the canonical envelope is always preserved by */ /* PayloadFilter::apply on the server). */ /* -------------------------------------------------------------------------- */ const FieldSelectorCard: React.FC<{ eventKey: string; eventName: string; paths: Array<{ path: string; sample: unknown }>; isLoading: boolean; sendAll: boolean; onChangeSendAll: (next: boolean) => void; selectedFields: string[]; onToggle: (path: string) => void; onSelectAll: () => void; onClear: () => void; samplePayload: Record | null; sampleSource: "captured" | "delivery_log" | null; sampleCapturedAt: number | null; }> = ({ eventKey, eventName, paths, isLoading, sendAll, onChangeSendAll, selectedFields, onToggle, onSelectAll, onClear, samplePayload, sampleSource, sampleCapturedAt, }) => { const queryClient = useQueryClient(); const { showToast } = useToast(); const [showRawPayload, setShowRawPayload] = useState(false); // Two-tab payload viewer: "raw" = real input data we captured; // "output" = what your receiver actually gets after field filtering // is applied. Defaults to "output" because that's the question // operators are most often trying to answer. const [payloadView, setPayloadView] = useState<"output" | "raw">("output"); // Pabbly-style listen mode — polls every 3s while armed. const listenQuery = useQuery({ queryKey: ["webhooks-listen", eventKey], queryFn: () => webhooksApi.getListenStatus(eventKey), enabled: eventKey !== "", refetchInterval: (q) => (q.state.data as ListenStatus | undefined)?.armed ? 3000 : false, }); const armed = listenQuery.data?.armed ?? false; const expiresAt = listenQuery.data?.expires_at ?? null; const armMutation = useMutation({ mutationFn: () => webhooksApi.startListen(eventKey), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["webhooks-listen", eventKey], }); }, onError: (e: any) => showToast(extractError(e), "error"), }); const disarmMutation = useMutation({ mutationFn: (forget: boolean) => webhooksApi.stopListen(eventKey, forget), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["webhooks-listen", eventKey], }); queryClient.invalidateQueries({ queryKey: ["webhooks-event-sample", eventKey], }); }, onError: (e: any) => showToast(extractError(e), "error"), }); // When a sample is captured live, refresh the sample query so the // dot-path list updates without a manual reload. React.useEffect(() => { if (listenQuery.data?.captured) { queryClient.invalidateQueries({ queryKey: ["webhooks-event-sample", eventKey], }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [listenQuery.data?.captured?.captured_at, eventKey, queryClient]); const formatSample = (val: unknown): string => { if (val === null || val === undefined) return "null"; if (typeof val === "string") return val === "" ? '""' : val; if (typeof val === "number" || typeof val === "boolean") return String(val); if (Array.isArray(val) || typeof val === "object") return JSON.stringify(val); return String(val); }; // Countdown timer for armed capture. const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)); React.useEffect(() => { if (!armed) return; const t = window.setInterval( () => setNow(Math.floor(Date.now() / 1000)), 1000, ); return () => window.clearInterval(t); }, [armed]); const remaining = armed && expiresAt ? Math.max(0, expiresAt - now) : 0; const mm = Math.floor(remaining / 60) .toString() .padStart(2, "0"); const ss = (remaining % 60).toString().padStart(2, "0"); const sourceLabel: Record<"captured" | "delivery_log", string> = { captured: __("Captured live", "yatra"), delivery_log: __("From last delivery", "yatra"), }; const hasSample = paths.length > 0 && samplePayload !== null; return (
{__("Payload fields to send", "yatra")} {__( "Pick which fields from this event's data block should be forwarded. The envelope (id, type, api_version, occurred_at) is always sent so receivers can route the event.", "yatra", )} {__( "Fields ship in the JSON body of the POST under their original keys — nested structure is preserved (e.g. customer.email arrives as data.customer.email, not flattened).", "yatra", )}
{hasSample && ( )}
{/* Listen control strip — uses Yatra's Alert primitive for * visual consistency with the rest of the admin. The * variant (info / success / warning) reflects the listen * state, and the action buttons live on the right. */}
{armed ? ( <>

{__( "Trigger this event in Yatra (create a booking, submit an enquiry, etc.). The real payload will appear here automatically.", "yatra", )}

{__("Expires in", "yatra")} {mm}:{ss}

) : hasSample ? ( <>

{__( "Pick from the real fields below — these are the exact paths your receiver will see.", "yatra", )}

{sampleCapturedAt && (

{__("Captured", "yatra")}{" "} {new Date(sampleCapturedAt * 1000).toLocaleString()}

)} ) : (

{__( 'Click "Listen for sample" then perform the action that triggers this event in Yatra. We\'ll show you the real payload — no guesswork.', "yatra", )}

)}
{armed ? ( ) : ( )} {hasSample && !armed && ( )}
{/* Forwarding mode — Send everything OR pick fields. */} {!sendAll && hasSample && (

{__( "Each row is a dot-path inside data{}. The value shown is from the real captured sample — your receiver will get that same key, at that same nesting.", "yatra", )}

{selectedFields.length} / {paths.length}{" "} {__("fields selected", "yatra")}
{isLoading ? (
{Array.from({ length: 8 }).map((_, i) => (
))}
) : (
{paths.map((row) => { const checked = selectedFields.includes(row.path); return ( ); })}
)}
)} {showRawPayload && samplePayload && (
{/* Tab strip — "Output" first because that's what operators * most need to verify: "what will my receiver actually get?" */}
              {JSON.stringify(
                payloadView === "output"
                  ? applyFieldFilter(
                      samplePayload,
                      sendAll ? [] : selectedFields,
                    )
                  : samplePayload,
                null,
                2,
              )}
            
{payloadView === "output" ? (

{sendAll || selectedFields.length === 0 ? __( "Send full payload mode — every field above ships in the POST body, exactly as shown.", "yatra", ) : __( "These are the exact JSON bytes that will be POSTed to your receiver. Keys stay nested under their original paths.", "yatra", )}

) : (

{sampleSource === "captured" ? __("Captured live from a real event firing.", "yatra") : __( "Pulled from the most recent successful delivery for this event.", "yatra", )}

)}
)} ); }; /* -------------------------------------------------------------------------- */ /* Endpoint create / edit form */ /* -------------------------------------------------------------------------- */ const EndpointEditForm: React.FC<{ existing: WebhookEndpoint | null; events: WebhookEvent[]; onClose: () => void; onCreated: (secret: string, endpointName: string) => void; }> = ({ existing, events, onClose, onCreated }) => { const queryClient = useQueryClient(); const { showToast } = useToast(); const isCreate = existing === null; const [name, setName] = useState(existing?.name ?? ""); const [url, setUrl] = useState(existing?.url ?? ""); const [description, setDescription] = useState(existing?.description ?? ""); // ONE event per endpoint (mirrors Email Automation's 1:1 model). // The selected_fields filter applies to this event's payload shape, // so binding multiple events here would make field selection ambiguous. const [eventKey, setEventKey] = useState(existing?.event ?? ""); const [showEventDropdown, setShowEventDropdown] = useState(false); // Outbound HTTP verb. POST default matches the universal webhook // convention; operators with non-POST receivers (PUT for upsert, // PATCH for partial, DELETE for object-removed mirrors, GET for // legacy polling-callback patterns) pick from the dropdown. const [httpMethod, setHttpMethod] = useState( existing?.http_method ?? "POST", ); const [isActive, setIsActive] = useState(existing?.is_active ?? true); const [logDeliveries, setLogDeliveries] = useState( existing?.log_deliveries ?? true, ); // Pabbly-style field filter. Empty = "send everything". Populated = // "send only these dot-paths" (envelope always preserved server-side). const [selectedFields, setSelectedFields] = useState( existing?.selected_fields ?? [], ); // "Send all data" is the default for new endpoints — toggling it off // reveals the dot-path checklist sourced from the event's sample payload. const [sendAllData, setSendAllData] = useState( (existing?.selected_fields?.length ?? 0) === 0, ); // Convert the server's map → list of {key, value} rows for the // KeyValueEditor (easier to manage incremental edits + ordering). // Empty trailing row is auto-managed by the editor itself. const [headerRows, setHeaderRows] = useState(() => objectToRows(existing?.headers ?? {}), ); const [extraRows, setExtraRows] = useState(() => objectToRows(existing?.additional_payload_fields ?? {}), ); const [useCustomSecret, setUseCustomSecret] = useState(false); const [customSecret, setCustomSecret] = useState(""); // Headers + extras are sent as plain key→value maps; the // KeyValueEditor strips blank rows so we don't transmit junk. const buildPayload = (): WebhookEndpointWriteInput => { const payload: WebhookEndpointWriteInput = { name, url, description, event: eventKey, http_method: httpMethod, headers: rowsToObject(headerRows), additional_payload_fields: rowsToObject(extraRows), // Empty list = "send everything"; the server keeps null in DB. selected_fields: sendAllData ? [] : selectedFields, is_active: isActive, log_deliveries: logDeliveries, }; if (useCustomSecret && customSecret.trim() !== "") { payload.custom_secret = customSecret.trim(); } return payload; }; const createMutation = useMutation({ mutationFn: () => webhooksApi.createEndpoint(buildPayload()), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["webhooks-endpoints"] }); onCreated(res.secret, res.data.name); }, onError: (e: any) => showToast(extractError(e), "error"), }); const updateMutation = useMutation({ mutationFn: () => webhooksApi.updateEndpoint(existing!.id, buildPayload()), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["webhooks-endpoints"] }); showToast(__("Endpoint updated.", "yatra"), "success"); onClose(); }, onError: (e: any) => showToast(extractError(e), "error"), }); // Look up the currently selected event row for the dropdown label + // description box. memo'd so we don't re-scan the list on every render. const selectedEvent = useMemo( () => events.find((e) => e.key === eventKey) ?? null, [events, eventKey], ); // Fetch the latest REAL payload for the picked event (captured or // pulled from the delivery log). When there's no sample yet, the // form prompts the operator to start a Listen session. const sampleQuery = useQuery({ queryKey: ["webhooks-event-sample", eventKey], queryFn: () => webhooksApi.getEventSample(eventKey), enabled: eventKey !== "", staleTime: 30_000, }); const samplePaths = sampleQuery.data?.paths ?? []; const samplePayload = sampleQuery.data?.payload ?? null; const sampleSource = sampleQuery.data?.source ?? null; const sampleCapturedAt = sampleQuery.data?.captured_at ?? null; const toggleField = (path: string) => { setSelectedFields((prev) => prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path], ); }; const selectAllFields = () => setSelectedFields(samplePaths.map((p) => p.path)); const clearAllFields = () => setSelectedFields([]); const customSecretValid = !useCustomSecret || customSecret.trim().length >= 24; // UX trap: if the operator picked "Send only selected fields" but // ticked zero, the server would silently treat it as "send // everything" — counter-intuitive. Block save until they either // pick at least one field or switch back to "Send full payload". const fieldSelectionValid = sendAllData || selectedFields.length > 0; const canSave = name.trim() !== "" && url.trim() !== "" && eventKey !== "" && customSecretValid && fieldSelectionValid && !createMutation.isPending && !updateMutation.isPending; return (

{isCreate ? __("Add endpoint", "yatra") : __("Edit endpoint", "yatra")}

{__("Destination", "yatra")} {__( "HTTPS-only. We sign every request with HMAC-SHA256 — your receiver verifies the X-Yatra-Signature header against your stored secret.", "yatra", )}
setName(e.target.value)} placeholder={__("Zapier — booking sync", "yatra")} className="mt-1" />
setUrl(e.target.value)} placeholder="https://hooks.zapier.com/hooks/catch/12345/abcdef/" className="mt-1 font-mono text-sm" /> {/* Inline scheme warning — caught at typing time so the * operator notices before saving. Plain http:// is rejected * server-side unless WP_DEBUG is on (dev-only). */} {url.trim().startsWith("http://") ? (
{__( "Insecure URL — http:// is only accepted in WP_DEBUG mode for local development. Production receivers must use https://.", "yatra", )}
) : (

{__("Must use HTTPS in production.", "yatra")}

)}
setDescription(e.target.value)} placeholder={__("Internal CRM ingest — sales team", "yatra")} className="mt-1" />
{httpMethod === "GET" ? (
{__( "GET deliveries send the payload as a ?payload= query parameter (URL-encoded). The HMAC signature is then computed over an empty body — receivers should reconstruct the signed string as `timestamp + '.' + ''`. Only use GET when the receiver explicitly requires it.", "yatra", )}
) : (

{__( "POST is the universal webhook convention. PUT / PATCH / DELETE are for receivers that explicitly require them (upsert, partial update, object-removal mirrors).", "yatra", )}

)}
{/* log_deliveries — high-volume endpoints turn off body * persistence so the delivery log doesn't bloat. Status, * duration, attempts still recorded so health metrics * + retry semantics stay intact. */}
{/* Trigger event — single select, matches the Email Automation * picker UX exactly (Zap icon button, indigo description box). */} {__("Trigger event", "yatra")} {__( "Pick the single event that triggers this endpoint. The field selector below will then show you the exact payload shape so you can choose which data to forward.", "yatra", )}
{showEventDropdown && (

{__( "Select the Yatra event that should fire this webhook", "yatra", )}

{events.map((event) => ( ))}
)} {showEventDropdown && (
setShowEventDropdown(false)} /> )}
{selectedEvent?.description && (

{selectedEvent.description}

)} {/* Pabbly-style field selector — only shown once an event is * picked, because the dot-path list depends on the event's * payload shape. */} {eventKey !== "" && ( { setSendAllData(next); if (next) setSelectedFields([]); }} selectedFields={selectedFields} onToggle={toggleField} onSelectAll={selectAllFields} onClear={clearAllFields} samplePayload={samplePayload} sampleSource={sampleSource} sampleCapturedAt={sampleCapturedAt} /> )} {__("Custom HTTP headers", "yatra")} {__( "Sent alongside Yatra's signed headers on every request. Useful for receivers that require Bearer / Basic auth on TOP of the HMAC signature, or for tagging requests for routing. Reserved headers (X-Yatra-*, Content-Length, Host, Cookie) are blocked.", "yatra", )} {__("Additional payload fields", "yatra")} {__( "Static fields merged into every payload's \"data\" block. Use this to tag events with operator-specific metadata (tenant_id, environment, region, source_app) without writing a server-side filter. Operator fields never overwrite Yatra's canonical entity fields.", "yatra", )} {/* Custom signing secret — only on create (rotate via the * Regenerate icon on the list view for existing endpoints, OR * by toggling this on while editing). */} {__("Signing secret", "yatra")} {isCreate ? __( "By default Yatra generates a strong random 32-byte signing secret. If your receiver already has a secret configured (e.g. you're migrating from another system), paste it below — minimum 24 characters.", "yatra", ) : __( "Leave unchecked to keep the existing secret. Check to rotate to a new one — receivers using the old secret will start failing signature verification until you update them.", "yatra", )}
{ setUseCustomSecret(next); if (!next) setCustomSecret(""); }} className="flex-shrink-0" />
{useCustomSecret && (
setCustomSecret(e.target.value)} placeholder={__( "Paste your signing secret (min 24 chars)", "yatra", )} className="font-mono text-sm" aria-describedby="webhook-custom-secret-help" />

{customSecret.length > 0 && customSecret.length < 24 ? ( {__("Too short — need at least 24 characters.", "yatra")} ( {customSecret.length}/24) ) : ( __( "Stored encrypted at rest. Treat it like a password.", "yatra", ) )}

)}
); }; /* -------------------------------------------------------------------------- */ /* Secret-reveal copy-once dialog */ /* -------------------------------------------------------------------------- */ const SecretRevealDialog: React.FC<{ secret: string | null; endpointName: string; onClose: () => void; }> = ({ secret, endpointName, onClose }) => { const [copied, setCopied] = useState(false); const [snippetLang, setSnippetLang] = useState<"php" | "node" | "python">( "php", ); const doCopy = async () => { if (!secret) return; try { await navigator.clipboard.writeText(secret); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch (_e) { // No-op — operator can select-all manually. } }; if (!secret) return null; // Receiver verification snippets — operator pastes into their handler. // Stripe-style "t=,v1=" signature header verification. const snippets: Record<"php" | "node" | "python", string> = { php: `// PHP receiver verification $signature = $_SERVER['HTTP_X_YATRA_SIGNATURE'] ?? ''; $body = file_get_contents('php://input'); $parts = []; foreach (explode(',', $signature) as $kv) { [$k, $v] = array_pad(explode('=', $kv, 2), 2, ''); $parts[$k] = $v; } $ts = (int) ($parts['t'] ?? 0); $sig = $parts['v1'] ?? ''; // Reject replays > 5 min old if (abs(time() - $ts) > 300) { http_response_code(400); exit; } $expected = hash_hmac('sha256', $ts . '.' . $body, '${secret}'); if (!hash_equals($expected, $sig)) { http_response_code(401); exit; } // Signature valid — process json_decode($body, true)`, node: `// Node.js (Express) receiver verification import crypto from 'crypto'; app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => { const sig = req.header('x-yatra-signature') || ''; const parts = Object.fromEntries( sig.split(',').map(kv => kv.split('=', 2)) ); const ts = parseInt(parts.t || '0', 10); // Reject replays > 5 min old if (Math.abs(Date.now() / 1000 - ts) > 300) return res.status(400).end(); const expected = crypto .createHmac('sha256', '${secret}') .update(\`\${ts}.\${req.body}\`) .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ''))) { return res.status(401).end(); } const event = JSON.parse(req.body); // …handle event });`, python: `# Python (Flask) receiver verification import hmac, hashlib, time from flask import request, abort @app.route('/webhook', methods=['POST']) def webhook(): sig = request.headers.get('X-Yatra-Signature', '') parts = dict(kv.split('=', 1) for kv in sig.split(',') if '=' in kv) ts = int(parts.get('t', '0')) if abs(time.time() - ts) > 300: abort(400) # replay protection expected = hmac.new( b'${secret}', f"{ts}.{request.data.decode()}".encode(), hashlib.sha256 ).hexdigest() if not hmac.compare_digest(expected, parts.get('v1', '')): abort(401) event = request.get_json() # …handle event`, }; return ( {__("Signing secret", "yatra")}
} size="lg" hideFooter={false} footer={
} >

{__("Below is the signing secret for ", "yatra")} {endpointName}.{" "} {__( "It will not be shown again — copy it now and store it in your receiver's configuration. If you lose it, you'll need to regenerate (which invalidates the old one).", "yatra", )}

e.currentTarget.select()} className="font-mono text-sm" aria-label={__("Signing secret value", "yatra")} />
{/* Receiver verification snippet — Stripe / GitHub UX: * reduces "how do I verify the signature?" support tickets * by handing the operator working code. */}
{(["php", "node", "python"] as const).map((lang) => ( ))}
            {snippets[snippetLang]}
          
{__( "Treat this secret like a password. Anyone with it can forge requests that appear to come from Yatra.", "yatra", )}
); }; /* -------------------------------------------------------------------------- */ /* Deliveries tab — "listen mode" / payload inspector */ /* -------------------------------------------------------------------------- */ const DeliveriesTab: React.FC = () => { const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState(""); const [endpointFilter, setEndpointFilter] = useState(0); const [eventFilter, setEventFilter] = useState(""); const [inspectId, setInspectId] = useState(null); const perPage = 25; React.useEffect(() => { setPage(1); }, [statusFilter, endpointFilter, eventFilter]); const { data, isLoading } = useQuery({ queryKey: [ "webhooks-deliveries", page, perPage, statusFilter, endpointFilter, eventFilter, ], queryFn: () => webhooksApi.listDeliveries({ page, per_page: perPage, ...(statusFilter ? { status: statusFilter } : {}), ...(endpointFilter ? { endpoint_id: endpointFilter } : {}), ...(eventFilter ? { event_key: eventFilter } : {}), }), refetchInterval: 10000, placeholderData: (prev) => prev, }); const { data: endpointsData } = useQuery({ queryKey: ["webhooks-endpoints"], queryFn: () => webhooksApi.listEndpoints(), }); const { data: eventsData } = useQuery({ queryKey: ["webhooks-events"], queryFn: () => webhooksApi.listEvents(), }); const queryClient = useQueryClient(); const { showToast } = useToast(); const replayMutation = useMutation({ mutationFn: (id: number) => webhooksApi.replayDelivery(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["webhooks-deliveries"] }); showToast(__("Delivery re-queued.", "yatra"), "success"); }, onError: (e: any) => showToast(extractError(e), "error"), }); const rows = data?.data ?? []; const total = data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / perPage)); const hasFilters = !!(statusFilter || endpointFilter || eventFilter); return (

{__("Delivery log", "yatra")}

{__( "Every outbound POST attempt with status, response, and the full payload that was sent. Click any row to inspect the JSON your receiver got.", "yatra", )}

{hasFilters && ( )}
{ const ep = endpointsData?.data.find( (e) => e.id === d.endpoint_id, ); return (
{/* Clicking the event/endpoint name opens the inspector * the same way clicking an activity name opens its edit. */}
→ {ep?.name ?? `#${d.endpoint_id}`}
); }, }, { key: "status", label: __("Status", "yatra"), render: (d: WebhookDeliveryRow) => ( ), }, { key: "attempts", label: __("Attempts", "yatra"), render: (d: WebhookDeliveryRow) => ( {d.attempts} ), }, { key: "duration_ms", label: __("Duration", "yatra"), render: (d: WebhookDeliveryRow) => d.duration_ms !== null ? ( {d.duration_ms} ms ) : ( ), }, { key: "created_at", label: __("Created", "yatra"), render: (d: WebhookDeliveryRow) => ( {d.created_at ? new Date(d.created_at).toLocaleString() : "—"} ), }, ]} actions={[ { key: "inspect", label: __("Inspect payload + response", "yatra"), icon: , onClick: (d: WebhookDeliveryRow) => setInspectId(d.id), }, { key: "replay", label: __("Replay delivery", "yatra"), icon: , onClick: (d: WebhookDeliveryRow) => replayMutation.mutate(d.id), condition: (d: WebhookDeliveryRow) => d.status === "failed" || d.status === "permanent_failure", }, ]} isLoading={isLoading} emptyText={ total === 0 && !hasFilters ? __("No deliveries yet", "yatra") : __("No matches", "yatra") } emptyDescription={ total === 0 && !hasFilters ? __( "Deliveries appear here once an event fires. Use the Ping button on an endpoint to fire a test event.", "yatra", ) : __("Try adjusting your filters to see more results.", "yatra") } /> {rows.length > 0 && totalPages > 1 && (
)}
setInspectId(null)} />
); }; /* -------------------------------------------------------------------------- */ /* mTLS dialog — upload / inspect / remove client cert per endpoint */ /* */ /* Three states: */ /* - configured=false: empty form (paste cert + key + optional passphrase) */ /* - configured=true: fingerprint + expiry hint + "Replace" / "Remove" */ /* - mid-mutation: spinner */ /* */ /* The cert+key never leave the server roundtrip — we paste, POST, then */ /* drop the strings from React state. Read-back only returns the hint. */ /* -------------------------------------------------------------------------- */ const MtlsDialog: React.FC<{ endpoint: WebhookEndpoint | null; onClose: () => void; }> = ({ endpoint, onClose }) => { const queryClient = useQueryClient(); const { showToast } = useToast(); const [cert, setCert] = useState(""); const [keyPem, setKeyPem] = useState(""); const [passphrase, setPassphrase] = useState(""); const [mode, setMode] = useState<"view" | "edit">("view"); const enabled = endpoint !== null; const { data, isLoading } = useQuery({ queryKey: ["webhook-mtls", endpoint?.id], queryFn: () => webhooksApi.getMtlsHint(endpoint!.id), enabled, }); const hint = data?.data; // Reset form whenever the dialog opens for a new endpoint. React.useEffect(() => { if (endpoint) { setCert(""); setKeyPem(""); setPassphrase(""); setMode(hint?.configured ? "view" : "edit"); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [endpoint?.id, hint?.configured]); const save = useMutation({ mutationFn: () => webhooksApi.setMtls(endpoint!.id, { cert, key: keyPem, passphrase }), onSuccess: (r) => { showToast(r.message, "success"); setCert(""); setKeyPem(""); setPassphrase(""); setMode("view"); void queryClient.invalidateQueries({ queryKey: ["webhook-mtls", endpoint!.id], }); }, onError: (e: any) => showToast(extractError(e), "error"), }); const clear = useMutation({ mutationFn: () => webhooksApi.clearMtls(endpoint!.id), onSuccess: (r) => { showToast(r.message, "success"); void queryClient.invalidateQueries({ queryKey: ["webhook-mtls", endpoint!.id], }); onClose(); }, onError: (e: any) => showToast(extractError(e), "error"), }); if (!endpoint) return null; return (
{__( "Optional. Some receivers require Yatra to authenticate at the TLS handshake with a client certificate, in addition to the HMAC signature on the body. Paste your PEM-encoded cert and private key below. The pair is encrypted at rest and used only at delivery time.", "yatra", )} {isLoading ? (
) : mode === "view" && hint?.configured ? (
{__("Client certificate configured", "yatra")}
{__("Fingerprint", "yatra")} {hint.fingerprint || __("(unavailable)", "yatra")}
{__("Expires", "yatra")} {hint.expires_at ? new Date(hint.expires_at).toLocaleString() : __("(unknown)", "yatra")}
) : (